diff options
253 files changed, 14789 insertions, 899 deletions
diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index 3473828484..f6c90b1ea1 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -87,6 +87,8 @@ body: label: Jellyfin Server version description: What version of Jellyfin are you using? options: + - 12.2 + - 12.1 - 12.0 - 10.11.11 - Master @@ -141,7 +143,7 @@ body: - **GPU Model**: [e.g. none, UHD630, GTX1050, etc.] - **Installed Plugins**: [e.g. none, Fanart, Anime, etc.] - **Reverse Proxy**: [e.g. none, nginx, apache, etc.] - - **Base URL**: [e.g. none, yes: /example] + - **Base URL**: [e.g. none, yes: /example - Do not include your domain name] - **Networking**: [e.g. Host, Bridge/NAT] - **Jellyfin Data Storage & Filesystem**: [e.g. local SATA SSD - ext4, local HDD - NTFS] - **Media Storage & Filesystem**: [e.g. Local HDD - ext4, SMB Share] diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml index 9a9f3214a7..5d7dd09348 100644 --- a/.github/workflows/project-automation.yml +++ b/.github/workflows/project-automation.yml @@ -1,66 +1,29 @@ name: Project Automation on: - push: - branches: - - master pull_request: - issue_comment: + types: [opened, reopened, synchronize, ready_for_review, edited] + pull_request_review: + types: [submitted] + pull_request_target: + types: [closed] + +permissions: + contents: read -permissions: {} jobs: - project: - name: Project board + project-board: + name: Release project board runs-on: ubuntu-latest - if: ${{ github.repository == 'jellyfin/jellyfin' }} + if: ${{ github.repository == 'jellyfin/jellyfin' && (github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository) }} steps: - - name: Remove from 'Current Release' project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') - continue-on-error: true - with: - project: Current Release - action: delete - column: In progress - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Add to 'Release Next' project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened' - continue-on-error: true - with: - project: Release Next - column: In progress - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Add to 'Current Release' project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') - continue-on-error: true - with: - project: Current Release - column: In progress - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Check number of comments from the team member - if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' - id: member_comments - run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)" - - - name: Move issue to needs triage - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1 - continue-on-error: true - with: - project: Issue Triage for Main Repo - column: Needs triage - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Add issue to triage project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: github.event.issue.pull_request == '' && github.event.action == 'opened' - continue-on-error: true + - name: Update project board + uses: jellyfin/jellyfin-packaging/.github/actions/project-automation@master with: - project: Issue Triage for Main Repo - column: Pending response - repo-token: ${{ secrets.JF_BOT_TOKEN }} + github-token: ${{ secrets.GH_PROJECTS_TOKEN }} + repo: jellyfin + event-name: ${{ github.event_name }} + base-ref: ${{ github.event.pull_request.base.ref }} + pr-number: ${{ github.event.pull_request.number }} + pr-merged: ${{ github.event.pull_request.merged }} + required-approvals: '2' diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index a97d335170..9012e35f1d 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) @@ -230,6 +231,7 @@ - [bjorntp](https://github.com/bjorntp) - [martenumberto](https://github.com/martenumberto) - [ZeusCraft10](https://github.com/ZeusCraft10) + - [AliveDevil](https://github.com/AliveDevil) - [MarcoCoreDuo](https://github.com/MarcoCoreDuo) - [LiHRaM](https://github.com/LiHRaM) - [MSalman5230](https://github.com/MSalman5230) @@ -241,6 +243,12 @@ - [m0g3r](https://github.com/m0g3r) - [martin-77](https://github.com/martin-77) - [Oggeb1](https://github.com/Oggeb1) + - [scr4bble](https://github.com/scr4bble) + - [Mateus Bandeira](https://github.com/mateusbandeiraa) + - [excelsior](https://github.com/excelsior-dev) + - [TheDreadPirate](https://github.com/thedreaddpirate) + - [Daniel Aquino](https://github.com/chino) + - [klizas](https://github.com/klizas) # Emby Contributors diff --git a/Directory.Packages.props b/Directory.Packages.props index 38cf513124..17ae5ac125 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -80,7 +80,7 @@ <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" /> <PackageVersion Include="System.Text.Json" Version="10.0.11" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" /> - <PackageVersion Include="z440.atl.core" Version="7.16.0" /> + <PackageVersion Include="z440.atl.core" Version="7.18.0" /> <PackageVersion Include="TMDbLib" Version="3.0.0" /> <PackageVersion Include="UTF.Unknown" Version="2.7.0" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 9ecefb4c7b..58d6e012f9 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -543,6 +543,12 @@ namespace Emby.Naming.Common MediaType.Video), new ExtraRule( + ExtraType.Sample, + ExtraRuleType.DirectoryName, + "sample", + MediaType.Video), + + new ExtraRule( ExtraType.Short, ExtraRuleType.DirectoryName, "shorts", diff --git a/Emby.Naming/TV/SeriesPathParser.cs b/Emby.Naming/TV/SeriesPathParser.cs index 94b4b5c823..1dd7a55aaf 100644 --- a/Emby.Naming/TV/SeriesPathParser.cs +++ b/Emby.Naming/TV/SeriesPathParser.cs @@ -20,6 +20,14 @@ namespace Emby.Naming.TV foreach (var expression in options.EpisodeExpressions) { + // Optimistic expressions (bare numbers, "01.blah", etc.) are only meant for + // episode parsing and produce false series names on release folder names like + // "Silo.S03.1080p.WEB-DL..." (e.g. reading "264" as S02E64). Skip them here. + if (expression.IsOptimistic) + { + continue; + } + var currentResult = Parse(path, expression); if (currentResult.Success) { @@ -49,8 +57,18 @@ namespace Emby.Naming.TV { if (expression.IsNamed) { + // Reject implausible season numbers (e.g. resolutions like 1280x720 + // read as S1280E720), mirroring EpisodePathParser. + var seasonNumberGroup = match.Groups["seasonnumber"]; + if (seasonNumberGroup.Success + && int.TryParse(seasonNumberGroup.ValueSpan, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var seasonNumber) + && ((seasonNumber >= 200 && seasonNumber < 1928) || seasonNumber > 2500)) + { + return result; + } + result.SeriesName = match.Groups["seriesname"].Value; - result.Success = !string.IsNullOrEmpty(result.SeriesName) && !match.Groups["seasonnumber"].ValueSpan.IsEmpty; + result.Success = !string.IsNullOrEmpty(result.SeriesName) && !seasonNumberGroup.ValueSpan.IsEmpty; } } diff --git a/Emby.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/ExtraRuleResolver.cs b/Emby.Naming/Video/ExtraRuleResolver.cs index 2e0caa612f..7b0ef411e9 100644 --- a/Emby.Naming/Video/ExtraRuleResolver.cs +++ b/Emby.Naming/Video/ExtraRuleResolver.cs @@ -30,7 +30,7 @@ namespace Emby.Naming.Video ReadOnlySpan<char> pathSpan = path.AsSpan(); ReadOnlySpan<char> fileName = Path.GetFileName(pathSpan); ReadOnlySpan<char> fileNameWithoutExtension = Path.GetFileNameWithoutExtension(pathSpan); - // Trim the digits from the end of the filename so we can recognize things like -trailer2 + // Trim the digits from the end of the filename so we can recognize things like -trailer2 or sample1 ReadOnlySpan<char> trimmedFileNameWithoutExtension = fileNameWithoutExtension.TrimEnd(_digits); ReadOnlySpan<char> directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan)); string fullDirectory = Path.GetDirectoryName(pathSpan).ToString(); @@ -45,7 +45,7 @@ namespace Emby.Naming.Video bool isMatch = rule.RuleType switch { - ExtraRuleType.Filename => fileNameWithoutExtension.Equals(rule.Token, StringComparison.OrdinalIgnoreCase), + ExtraRuleType.Filename => trimmedFileNameWithoutExtension.Equals(rule.Token, StringComparison.OrdinalIgnoreCase), ExtraRuleType.Suffix => trimmedFileNameWithoutExtension.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase), ExtraRuleType.Regex => Regex.IsMatch(fileName, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled), ExtraRuleType.DirectoryName => directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase) 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/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs index 69cbe533c6..2938170d1b 100644 --- a/Emby.Server.Implementations/Chapters/ChapterManager.cs +++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs @@ -137,6 +137,7 @@ public class ChapterManager : IChapterManager var success = true; var changesMade = false; + var extractedCount = 0; var runtimeTicks = video.RunTimeTicks ?? 0; @@ -178,7 +179,7 @@ public class ChapterManager : IChapterManager Protocol = video.PathProtocol ?? MediaProtocol.File, }; - _logger.LogInformation("Extracting chapter image for {Name} at {Path}", video.Name, inputPath); + _logger.LogDebug("Extracting chapter image for {Name} at {Time}", video.Name, time); var tempFile = await _encoder.ExtractVideoImage(inputPath, container, mediaSource, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false); File.Copy(tempFile, path, true); @@ -194,6 +195,7 @@ public class ChapterManager : IChapterManager chapter.ImagePath = path; chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path); changesMade = true; + extractedCount++; } catch (Exception ex) { @@ -222,6 +224,11 @@ public class ChapterManager : IChapterManager } } + if (extractedCount > 0) + { + _logger.LogInformation("Extracted {Count} chapter images for {Name} at {Path}", extractedCount, video.Name, video.Path); + } + if (saveChapters && changesMade) { SaveChapters(video, chapters); diff --git a/Emby.Server.Implementations/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/Data/CleanDatabaseScheduledTask.cs b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs index 17355960c3..81ae9a536b 100644 --- a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs +++ b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs @@ -113,7 +113,7 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); await using (transaction.ConfigureAwait(false)) { - await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await context.ItemValues.Where(e => !e.BaseItemsMap!.Any()).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); subProgress.Report(50); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); subProgress.Report(100); diff --git a/Emby.Server.Implementations/Data/ItemTypeLookup.cs b/Emby.Server.Implementations/Data/ItemTypeLookup.cs index 82c0a8b6c5..03117f6de5 100644 --- a/Emby.Server.Implementations/Data/ItemTypeLookup.cs +++ b/Emby.Server.Implementations/Data/ItemTypeLookup.cs @@ -1,8 +1,8 @@ using System.Collections.Frozen; using System.Collections.Generic; -using System.Threading.Channels; using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; diff --git a/Emby.Server.Implementations/Data/RefreshDatabaseStatisticsPostScanTask.cs b/Emby.Server.Implementations/Data/RefreshDatabaseStatisticsPostScanTask.cs new file mode 100644 index 0000000000..be1fe660c9 --- /dev/null +++ b/Emby.Server.Implementations/Data/RefreshDatabaseStatisticsPostScanTask.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Data; + +/// <summary> +/// Refreshes the database statistics after every library scan. +/// </summary> +/// <remarks> +/// The scheduled optimization skips itself while a scan runs, so without this the first scan of a new server +/// leaves every query planned for an empty library until the next scheduled run. +/// </remarks> +public class RefreshDatabaseStatisticsPostScanTask : ILibraryPostScanTask +{ + private readonly IJellyfinDatabaseProvider _databaseProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="RefreshDatabaseStatisticsPostScanTask"/> class. + /// </summary> + /// <param name="databaseProvider">The database provider.</param> + public RefreshDatabaseStatisticsPostScanTask(IJellyfinDatabaseProvider databaseProvider) + { + _databaseProvider = databaseProvider; + } + + /// <inheritdoc /> + public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) + { + await _databaseProvider.RefreshStatistics(cancellationToken).ConfigureAwait(false); + progress.Report(100); + } +} diff --git a/Emby.Server.Implementations/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/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index 7cae2a671b..1d3cc3d383 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Enums; @@ -12,6 +13,7 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -20,8 +22,11 @@ namespace Emby.Server.Implementations.Images { public class CollectionFolderImageProvider : BaseDynamicImageProvider<CollectionFolder> { - public CollectionFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor) : base(fileSystem, providerManager, applicationPaths, imageProcessor) + private readonly ILibraryManager _libraryManager; + + public CollectionFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) { + _libraryManager = libraryManager; } protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item) @@ -33,8 +38,11 @@ namespace Emby.Server.Implementations.Images if (viewType == CollectionType.music) { - // Music albums usually don't have dedicated backdrops, so use artist instead - includeItemTypes = [BaseItemKind.MusicArtist]; + // Music albums usually don't have dedicated backdrops, so use artist instead. + // Artists carry no library of their own, so an item query for them is not + // restricted to this library and would collage the artists of every music + // library. Resolve them through the tracks that credit them instead. + return GetArtistsWithImages(view); } return view.GetItemList(new InternalItemsQuery @@ -49,6 +57,19 @@ namespace Emby.Server.Implementations.Images }); } + private IReadOnlyList<BaseItem> GetArtistsWithImages(CollectionFolder view) + { + return _libraryManager.GetAllArtists(new InternalItemsQuery + { + AncestorIds = [view.Id], + DtoOptions = new DtoOptions(false), + EnableTotalRecordCount = false, + ImageTypes = [ImageType.Primary], + Limit = 8, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }).Items.Select(i => i.Item).ToArray(); + } + protected override bool Supports(BaseItem item) { return item is CollectionFolder; diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 0044fcd4dc..16616dd328 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; + } } } @@ -1343,7 +1354,6 @@ namespace Emby.Server.Implementations.Library { IncludeItemTypes = [BaseItemKind.MusicArtist], Name = name, - UseRawName = true, DtoOptions = options }).Cast<MusicArtist>() .OrderBy(i => i.IsAccessedByName ? 1 : 0) @@ -2442,6 +2452,9 @@ namespace Emby.Server.Implementations.Library var parentCollectionType = parent is not null ? GetTopFolderContentType(parent) : null; foreach (var item in items) { + // Parental controls filter on the stored score, so it has to match the ratings being saved. + item.OnMetadataChanged(); + if (item is Video video && video.LocalAlternateVersions.Length > 0) { var videoType = video.GetType(); @@ -2648,6 +2661,9 @@ namespace Emby.Server.Implementations.Library { foreach (var item in items) { + // Parental controls filter on the stored score, so it has to match the ratings being saved. + item.OnMetadataChanged(); + item.DateLastSaved = DateTime.UtcNow; await RunMetadataSavers(item, updateReason).ConfigureAwait(false); @@ -3042,14 +3058,7 @@ namespace Emby.Server.Implementations.Library CollectionType? viewType, string sortName) { - var parentIdString = parentId.IsEmpty() - ? null - : parentId.ToString("N", CultureInfo.InvariantCulture); - - // The name is either localized (grouped views) or the library folder's own name. - var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); - - var id = GetNewItemId(idValues, typeof(UserView)); + var id = GetNamedViewId(user, parentId, viewType); var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", id.ToString("N", CultureInfo.InvariantCulture)); @@ -3107,6 +3116,18 @@ namespace Emby.Server.Implementations.Library return item; } + public Guid GetNamedViewId(User user, Guid parentId, CollectionType? viewType) + { + var parentIdString = parentId.IsEmpty() + ? null + : parentId.ToString("N", CultureInfo.InvariantCulture); + + // The name is either localized (grouped views) or the library folder's own name. + var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); + + return GetNewItemId(idValues, typeof(UserView)); + } + public UserView GetShadowView( BaseItem parent, CollectionType? viewType, @@ -3116,10 +3137,7 @@ namespace Emby.Server.Implementations.Library var name = parent.Name; var parentId = parent.Id; - - var idValues = "38_namedview_" + name + parentId + (viewType?.ToString() ?? string.Empty); - - var id = GetNewItemId(idValues, typeof(UserView)); + var id = GetShadowViewId(name, parentId, viewType); var path = parent.Path; @@ -3171,6 +3189,13 @@ namespace Emby.Server.Implementations.Library return item; } + public Guid GetShadowViewId(string name, Guid parentId, CollectionType? viewType) + { + var idValues = "38_namedview_" + name + parentId + (viewType?.ToString() ?? string.Empty); + + return GetNewItemId(idValues, typeof(UserView)); + } + public UserView GetNamedView( string name, Guid parentId, @@ -3456,6 +3481,7 @@ namespace Emby.Server.Implementations.Library var extras = new List<BaseItem>(); var typeCounters = new Dictionary<ExtraType, int>(); + var generatedNames = new Dictionary<ExtraType, HashSet<string>>(); // Order by path so that the numbering handed out below does not depend on the // order the file system happened to list the folder in @@ -3492,10 +3518,12 @@ namespace Emby.Server.Implementations.Library extra = itemById; } - // An extra is named after its file, so the file is the source of truth. Items created - // by older versions, or renamed by a metadata provider, are corrected here; - // RefreshExtras persists the change. - if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true) + // The name derived from the file is only a default. A name that came from anywhere else, + // such as a local metadata file, is the user's and has to survive the scan, so only a + // name this method handed out itself is renewed; RefreshExtras persists the change. + if (!string.IsNullOrEmpty(name) + && extra.LockedFields?.Contains(MetadataField.Name) != true + && (itemById is null || IsGeneratedExtraName(extra.Name, candidate))) { extra.Name = name; } @@ -3517,6 +3545,31 @@ namespace Emby.Server.Implementations.Library return null; } + + bool IsGeneratedExtraName(string currentName, ExtraCandidate candidate) + { + // The file name is what an extra was called before it was given a name of its type + if (string.Equals(currentName, candidate.Extra.Name, StringComparison.Ordinal)) + { + return true; + } + + if (!generatedNames.TryGetValue(candidate.ExtraType, out var names)) + { + // Any of the numbers of this type may have been handed out, as the order the extras + // of a type are numbered in shifts as files appear beside them or are taken away + names = new HashSet<string>(StringComparer.Ordinal); + var count = candidates.Count(c => c.ExtraType == candidate.ExtraType); + for (var seen = 0; seen < count; seen++) + { + names.Add(GetNumberedExtraName(candidate.ExtraType, seen)); + } + + generatedNames[candidate.ExtraType] = names; + } + + return names.Contains(currentName); + } } /// <summary> @@ -3543,7 +3596,18 @@ namespace Emby.Server.Implementations.Library typeCounters.TryGetValue(candidate.ExtraType, out var seen); typeCounters[candidate.ExtraType] = seen + 1; - var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType)); + return GetNumberedExtraName(candidate.ExtraType, seen); + } + + /// <summary> + /// Gets the name given to the n-th extra of a type that is named after its type. + /// </summary> + /// <param name="extraType">The extra type.</param> + /// <param name="seen">Number of extras of the type named before this one.</param> + /// <returns>The name.</returns> + private string GetNumberedExtraName(ExtraType extraType, int seen) + { + var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(extraType)); return seen == 0 ? typeName @@ -3608,6 +3672,11 @@ namespace Emby.Server.Implementations.Library public QueryResult<BaseItem> GetPeopleItems(InternalPeopleQuery query) { + ArgumentNullException.ThrowIfNull(query); + + // This hands back by-name items, so the people without one are not ours to report. + query.MustHaveItem = true; + var queryResult = _peopleRepository.GetPeople(query); var baseItems = queryResult.Items.Select(i => { @@ -3799,7 +3868,7 @@ namespace Emby.Server.Implementations.Library if (refreshLibrary) { - StartScanInBackground(); + _ = StartScanInBackground(); } else { @@ -3867,13 +3936,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 +4056,7 @@ namespace Emby.Server.Implementations.Library { await ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); - StartScanInBackground(); + _ = StartScanInBackground(); } else { @@ -4128,6 +4200,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..a5ea02df51 100644 --- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -10,6 +10,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; @@ -36,6 +37,7 @@ public class SqlSearchProvider : IInternalSearchProvider private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly IItemQueryHelpers _queryHelpers; + private readonly IServerConfigurationManager _configurationManager; /// <summary> /// Initializes a new instance of the <see cref="SqlSearchProvider"/> class. @@ -45,18 +47,21 @@ public class SqlSearchProvider : IInternalSearchProvider /// <param name="libraryManager">The library manager.</param> /// <param name="userManager">The user manager.</param> /// <param name="queryHelpers">The shared item query helpers.</param> + /// <param name="configurationManager">The configuration manager.</param> public SqlSearchProvider( IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, ILibraryManager libraryManager, IUserManager userManager, - IItemQueryHelpers queryHelpers) + IItemQueryHelpers queryHelpers, + IServerConfigurationManager configurationManager) { _dbProvider = dbProvider; _itemTypeLookup = itemTypeLookup; _libraryManager = libraryManager; _userManager = userManager; _queryHelpers = queryHelpers; + _configurationManager = configurationManager; } /// <inheritdoc/> @@ -98,6 +103,12 @@ public class SqlSearchProvider : IInternalSearchProvider // so match it via a case-insensitive LIKE rather than a per-row case conversion // that may not translate to SQL on every provider. var likeOriginal = $"%{rawSearchTerm}%"; + + // Great benefit for users with metadata in non-latin script + // since SortName is among other things stored transliterated. + var sortNameShape = BaseItem.GetSortName(rawSearchTerm, true, _configurationManager.Configuration); + var likeSortName = string.IsNullOrWhiteSpace(sortNameShape) ? null : $"%{sortNameShape}%"; + var limit = query.Limit ?? DefaultSearchLimit; var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); @@ -107,14 +118,27 @@ public class SqlSearchProvider : IInternalSearchProvider var dbQuery = dbContext.BaseItems .AsNoTracking() .Where(e => e.Id != _placeholderId) - .Where(e => !e.IsVirtualItem) - .Where(e => e.CleanName!.Contains(cleanSearchTerm) - || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal))); + .Where(e => !e.IsVirtualItem); + + if (likeSortName is null) + { + dbQuery = dbQuery + .Where(e => e.CleanName!.Contains(cleanSearchTerm) + || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal))); + } + else + { + dbQuery = dbQuery + .Where(e => e.CleanName!.Contains(cleanSearchTerm) + || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)) + || (e.SortName != null && EF.Functions.Like(e.SortName, likeSortName))); + } dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes); 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 +217,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/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 0680046c11..1e7068b24b 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -75,6 +75,22 @@ namespace Emby.Server.Implementations.Library } dbContext.SaveChanges(); + + // Rows under keys the item no longer reports are kept, not deleted: the key set is briefly + // incomplete mid-refresh, and those rows are what a deleted and re-added item reattaches by. + dbContext.UserData + .Where(e => e.ItemId == item.Id && e.UserId == user.Id && !keys.Contains(e.CustomDataKey)) + .ExecuteUpdate(e => e + .SetProperty(f => f.AudioStreamIndex, userData.AudioStreamIndex) + .SetProperty(f => f.IsFavorite, userData.IsFavorite) + .SetProperty(f => f.LastPlayedDate, userData.LastPlayedDate) + .SetProperty(f => f.Likes, userData.Likes) + .SetProperty(f => f.PlaybackPositionTicks, userData.PlaybackPositionTicks) + .SetProperty(f => f.PlayCount, userData.PlayCount) + .SetProperty(f => f.Played, userData.Played) + .SetProperty(f => f.Rating, userData.Rating) + .SetProperty(f => f.SubtitleStreamIndex, userData.SubtitleStreamIndex)); + transaction.Commit(); var userId = user.InternalId; diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 47b3891901..6d52ce7440 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -62,12 +62,11 @@ namespace Emby.Server.Implementations.Library // Playlist and BoxSet libraries require special handling because the folder only references linked items if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) { - var items = folder.GetItemList(new InternalItemsQuery(user) - { - ParentId = folder.ParentId - }); + var itemKind = folderViewType == CollectionType.playlists + ? BaseItemKind.Playlist + : BaseItemKind.BoxSet; - if (!items.Any(item => item.IsVisible(user))) + if (!HasVisibleItem(itemKind, folders, user)) { continue; } @@ -127,7 +126,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 +158,32 @@ namespace Emby.Server.Implementations.Library .ToArray(); } + private bool HasVisibleItem(BaseItemKind itemKind, IReadOnlyList<Folder> folders, User user) + { + var topParentIds = folders.SelectMany(GetTopParentIds).ToArray(); + if (topParentIds.Length == 0) + { + return false; + } + + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = [itemKind], + TopParentIds = topParentIds, + GroupByPresentationUniqueKey = false, + DtoOptions = DtoOptions.StoredColumnsOnly + }); + + return items.Any(item => item.IsVisible(user)); + } + + private static IEnumerable<Guid> GetTopParentIds(Folder folder) + { + return folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0 + ? collectionFolder.PhysicalFolderIds + : [folder.Id]; + } + public UserView GetUserSubViewWithName(string name, Guid parentId, CollectionType? type, string sortName) { var uniqueId = parentId + "subview" + type; diff --git a/Emby.Server.Implementations/Localization/Core/bs.json b/Emby.Server.Implementations/Localization/Core/bs.json index 5686807d9a..98dcc68ed1 100644 --- a/Emby.Server.Implementations/Localization/Core/bs.json +++ b/Emby.Server.Implementations/Localization/Core/bs.json @@ -118,5 +118,8 @@ "NameExtraShort": "Kratko", "NameExtraThemeSong": "Tema", "NameExtraThemeVideo": "Tematski video", - "NameExtraTrailer": "Najava" + "NameExtraTrailer": "Najava", + "LyricDownloadFailureFromForItem": "Tekstovi nisu uspješno preuzeti sa {0} za {1}", + "NameExtraUnknown": "Ekstra", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 033002d2b2..cfc16d553c 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -1,6 +1,6 @@ { "AppDeviceValues": "Aplikace: {0}, Zařízení: {1}", - "Artists": "Umělci", + "Artists": "Interpreti", "AuthenticationSucceededWithUserName": "{0} úspěšně ověřen", "Books": "Knihy", "ChapterNameValue": "Kapitola {0}", diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index 6e5532ead9..4c7d16a9a7 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -10,7 +10,7 @@ "Folders": "Mapper", "Genres": "Genrer", "HeaderContinueWatching": "Fortsæt med at se", - "HeaderFavoriteEpisodes": "Yndlingsafsnit", + "HeaderFavoriteEpisodes": "Favoritepisoder", "HeaderFavoriteShows": "Yndlingsserier", "HeaderLiveTV": "Live-TV", "HeaderNextUp": "Næste", diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index 9a453120dd..12d50cf89a 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -120,5 +120,6 @@ "NameExtraShort": "Lyhytfilmi", "NameExtraThemeSong": "Tunnusmusiikki", "NameExtraThemeVideo": "Tunnusvideo", - "NameExtraTrailer": "Traileri" + "NameExtraTrailer": "Traileri", + "NameExtraUnknown": "Lisät" } diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 023ed6ec5e..b9964b02b8 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -7,7 +7,7 @@ "AppDeviceValues": "App: {0}, Eind: {1}", "Books": "Bøkur", "ChapterNameValue": "Kapittul {0}", - "Favorites": "Yndislista", + "Favorites": "Yndislisti", "Folders": "Skjáttur", "Forced": "Kravt", "FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}", @@ -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", @@ -49,7 +49,7 @@ "Movies": "Filmar", "MixedContent": "Blandað innihald", "Music": "Tónleikur", - "UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}", + "UserStartedPlayingItemWithValues": "{0} spælir {1} á {2}", "HeaderContinueWatching": "Hald áfram at hyggja", "MusicVideos": "Sjónbandaløg", "TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.", @@ -69,7 +69,7 @@ "TasksApplicationCategory": "Nýtsluskipan", "NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk", "NotificationOptionApplicationUpdateInstalled": "Skipanardagføring var innløgd", - "UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}", + "UserStoppedPlayingItemWithValues": "{0} gavst at spæla {1} á {2}", "HomeVideos": "Heimaupptøkur", "StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.", "UserOfflineFromDevice": "{0} breyt av frá {1}", @@ -96,9 +96,9 @@ "TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.", "TaskRefreshChapterImages": "Kapitlamyndaúttøkur", "NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað", - "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað", + "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl er steðgað", "NotificationOptionAudioPlayback": "Ljóðspæl byrjað", - "NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað", + "NotificationOptionAudioPlaybackStopped": "Ljóðspæl er steðgað", "TaskExtractMediaSegments": "Leita eftir margmiðlabrotum", "TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.", "NotificationOptionCameraImageUploaded": "Ljósmynd uppsend", diff --git a/Emby.Server.Implementations/Localization/Core/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/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index 78b7ec744b..24008575d4 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -2,18 +2,18 @@ "AppDeviceValues": "アプリ: {0}, デバイス: {1}", "Artists": "アーティスト", "AuthenticationSucceededWithUserName": "{0} 認証に成功しました", - "Books": "ブック", + "Books": "書籍", "ChapterNameValue": "チャプター {0}", "Collections": "コレクション", "FailedLoginAttemptWithUserName": "{0} からのログインに失敗しました", "Favorites": "お気に入り", "Folders": "フォルダー", "Genres": "ジャンル", - "HeaderContinueWatching": "再生を続ける", + "HeaderContinueWatching": "視聴をつづける", "HeaderFavoriteEpisodes": "お気に入りのエピソード", "HeaderFavoriteShows": "お気に入りの番組", "HeaderLiveTV": "ライブTV", - "HeaderNextUp": "次", + "HeaderNextUp": "次に再生", "HomeVideos": "ホームビデオ", "Inherit": "継承", "LabelIpAddressValue": "IPアドレス: {0}", @@ -41,7 +41,7 @@ "NotificationOptionServerRestartRequired": "サーバーを再起動してください", "NotificationOptionTaskFailed": "スケジュールされていたタスクの失敗", "NotificationOptionUserLockedOut": "ユーザーはロックされています", - "NotificationOptionVideoPlayback": "ビデオの再生を開始", + "NotificationOptionVideoPlayback": "動画の再生を開始", "NotificationOptionVideoPlaybackStopped": "ビデオの再生を停止", "Photos": "フォト", "PluginInstalledWithName": "{0} をインストールしました", @@ -80,10 +80,10 @@ "TaskUpdatePlugins": "プラグインの更新", "TaskRefreshPeopleDescription": "メディアライブラリー内の俳優や監督のメタデータを更新します。", "TaskRefreshPeople": "俳優や監督のデータの更新", - "TaskDownloadMissingSubtitlesDescription": "メタデータ構成に基づいて、欠落している字幕をインターネットで検索する。", - "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネイルを作成します。", + "TaskDownloadMissingSubtitlesDescription": "メタデータ設定をもとに、欠落している字幕をインターネットで検索します。", + "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネールを作成します。", "TaskRefreshChapterImages": "チャプター画像を抽出する", - "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする", + "TaskDownloadMissingSubtitles": "欠けている字幕をダウンロード", "TaskCleanActivityLogDescription": "設定された期間よりも古いアクティビティの履歴を削除します。", "TaskCleanActivityLog": "アクティビティの履歴を消去", "Undefined": "未定義", @@ -91,7 +91,7 @@ "Default": "デフォルト", "TaskOptimizeDatabaseDescription": "データベースをコンパクトにして、空き領域を切り詰めます。メディアライブラリーのスキャンやその他のデータベースの更新を伴う変更の後でこのタスクを実行すると、パフォーマンスが向上します。", "TaskOptimizeDatabase": "データベースの最適化", - "TaskKeyframeExtractorDescription": "より正確なHLSプレイリストを作成するため、動画ファイルからキーフレームを抽出する。この処理には時間がかかる場合があります。", + "TaskKeyframeExtractorDescription": "動画ファイルからキーフレームを抽出し、より正確な HLS プレーリストを作成します。この処理には時間がかかる場合があります。", "TaskKeyframeExtractor": "キーフレーム抽出", "External": "外部", "HearingImpaired": "聴覚障害の方", @@ -103,10 +103,23 @@ "TaskExtractMediaSegments": "メディアセグメントを読み取る", "TaskMoveTrickplayImages": "Trickplayの画像を移動", "TaskMoveTrickplayImagesDescription": "ライブラリ設定によりTrickplayのファイルを移動。", - "TaskDownloadMissingLyrics": "失われた歌詞をダウンロード", + "TaskDownloadMissingLyrics": "欠けている歌詞をダウンロード", "TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。", "CleanupUserDataTask": "ユーザーデータのクリーンアップタスク", "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。", "LyricDownloadFailureFromForItem": "歌詞", - "Original": "オリジナル" + "Original": "オリジナル", + "NameExtraThemeVideo": "テーマビデオ", + "NameExtraBehindTheScenes": "舞台裏", + "NameExtraClip": "クリップ", + "NameExtraDeletedScene": "未公開シーン", + "NameExtraFeaturette": "短編", + "NameExtraInterview": "インタビュー", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "サンプル", + "NameExtraScene": "シーン", + "NameExtraShort": "ショート", + "NameExtraThemeSong": "テーマ曲", + "NameExtraTrailer": "予告編", + "NameExtraUnknown": "特典" } diff --git a/Emby.Server.Implementations/Localization/Core/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/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index dccec8067d..6baaeca524 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -120,5 +120,6 @@ "NameExtraShort": "Scurt", "NameExtraThemeSong": "Audio de Fundal", "NameExtraThemeVideo": "Video de Fundal", - "NameExtraTrailer": "Trailer" + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 67f4ecc4d5..87f49ae80d 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -81,8 +81,8 @@ "TaskCleanLogs": "Günlük Dizinini Temizle", "TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve ortam bilgilerini yeniler.", "TaskRefreshLibrary": "Medya Kütüphanesini Tara", - "TaskRefreshChapterImagesDescription": "Bölümlere ayrılmış videolar için küçük resimler oluştur.", - "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar", + "TaskRefreshChapterImagesDescription": "Video bölümleri için küçük görseller oluştur.", + "TaskRefreshChapterImages": "Bölüm görsellerini çıkar", "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.", "TaskCleanActivityLog": "Etkinlik Günlüğünü Temizle", "TaskCleanActivityLogDescription": "Yapılandırılan tarihten daha eski olan etkinlik günlüğü girişlerini siler.", diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json index b3f24a31e3..a8ec31aed4 100644 --- a/Emby.Server.Implementations/Localization/Core/ur_PK.json +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -98,5 +98,28 @@ "TaskDownloadMissingLyrics": "غائب بول ڈاؤن لوڈ کریں", "TaskDownloadMissingLyricsDescription": "گانے کے غائب بول ڈاؤن لوڈ کریں", "TaskAudioNormalization": "آڈیو نارملائزیشن", - "TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔" + "TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔", + "LyricDownloadFailureFromForItem": "{1} کے لیے {0} سے دھن ڈاؤن لوڈ کرنے میں ناکامی", + "Original": "اصل", + "TaskRefreshTrickplayImages": "Trickplay تصاویر بنائیں", + "TaskRefreshTrickplayImagesDescription": "فعال لائبریریوں میں ویڈیوز کے لیے Trickplay پیش منظر بناتا ہے۔", + "TaskExtractMediaSegments": "میڈیا سیگمنٹ اسکین", + "TaskExtractMediaSegmentsDescription": "MediaSegment فعال پلگ انز سے میڈیا سیگمنٹس اخذ یا حاصل کرتا ہے۔", + "TaskMoveTrickplayImages": "Trickplay تصاویر کا مقام منتقل کریں", + "TaskMoveTrickplayImagesDescription": "لائبریری کی ترتیبات کے مطابق موجودہ Trickplay فائلیں منتقل کرتا ہے۔", + "CleanupUserDataTask": "صارف ڈیٹا صفائی کا ٹاسک", + "CleanupUserDataTaskDescription": "کم از کم 90 دن سے موجود نہ ہونے والے میڈیا سے تمام صارف ڈیٹا (دیکھنے کی حالت، پسندیدہ حیثیت وغیرہ) صاف کرتا ہے۔", + "NameExtraBehindTheScenes": "پسِ پردہ", + "NameExtraClip": "کلپ", + "NameExtraDeletedScene": "حذف شدہ منظر", + "NameExtraFeaturette": "فیچریٹ", + "NameExtraInterview": "انٹرویو", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "نمونہ", + "NameExtraScene": "منظر", + "NameExtraShort": "مختصر", + "NameExtraThemeSong": "تھیم سونگ", + "NameExtraThemeVideo": "تھیم ویڈیو", + "NameExtraTrailer": "ٹریلر", + "NameExtraUnknown": "اضافی" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 9590d9c1c9..7c61ce6f17 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -22,7 +22,7 @@ "MixedContent": "混合内容", "Movies": "电影", "Music": "音乐", - "MusicVideos": "MV", + "MusicVideos": "音乐视频", "NameInstallFailed": "{0} 安装失败", "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季", diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 0331ec39e5..7af545c550 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,33 +375,71 @@ 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; + } + var hasUnknownRating = false; foreach (var ratingValue in ratingValues) { - var score = GetSingleRatingScore(ratingValue, countryCode); + // A single entry of such a list may be unrated while a later one still resolves + if (IsUnrated(ratingValue)) + { + continue; + } + + // An unknown entry is not worth a warning of its own as long as another entry still resolves + var score = GetSingleRatingScore(ratingValue, countryCode, logUnknownRating: false); if (score is not null) { return score; } + + hasUnknownRating = true; + } + + if (hasUnknownRating) + { + _logger.LogWarning( + "None of the ratings in '{Rating}' were found in a known rating system, treating as unrated", + rating); } return null; } /// <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) + private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode, bool logUnknownRating = true) { - // 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)) @@ -455,9 +494,11 @@ namespace Emby.Server.Implementations.Localization } } - // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18" - if (TryGetRatingScoreBySeparator(rating, ':', out var result) - || TryGetRatingScoreBySeparator(rating, '-', out result)) + // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18". + // A '/' marks a list of ratings, which this would split into the first entry's country and the last entry's rating + if (!rating.Contains('/', StringComparison.Ordinal) + && (TryGetRatingScoreBySeparator(rating, ':', logUnknownRating, out var result) + || TryGetRatingScoreBySeparator(rating, '-', logUnknownRating, out result))) { return result; } @@ -465,7 +506,7 @@ namespace Emby.Server.Implementations.Localization return null; } - private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result) + private bool TryGetRatingScoreBySeparator(string rating, char separator, bool logUnknownRating, out ParentalRatingScore? result) { result = null; @@ -512,10 +553,19 @@ namespace Emby.Server.Implementations.Localization return true; } - _logger.LogWarning( - "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", - rating, - resolvedCountryCode); + // Explicitly unrated content (e.g. "IT-NR") is unrated by definition, not a lookup failure + if (IsUnrated(ratingPart)) + { + return true; + } + + if (logUnknownRating) + { + _logger.LogWarning( + "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", + rating, + resolvedCountryCode); + } return true; } @@ -527,14 +577,16 @@ namespace Emby.Server.Implementations.Localization } /// <summary> - /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+"). + /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+") + /// or a leading '-' (e.g. the French "-12"). /// </summary> /// <param name="ratingValue">Rating value to parse.</param> /// <param name="score">Parsed score.</param> /// <returns>Returns true if parsing was successful.</returns> private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score) { - var trimmed = ratingValue.TrimEnd('+'); + // A leading '-' marks a minimum age ("-12" is French for "not for under 12s"), never a negative score + var trimmed = ratingValue.TrimStart('-').TrimEnd('+'); return int.TryParse(trimmed, out score); } diff --git a/Emby.Server.Implementations/Localization/Ratings/0-prefer.json b/Emby.Server.Implementations/Localization/Ratings/0-prefer.json index b390151611..dd88b35363 100644 --- a/Emby.Server.Implementations/Localization/Ratings/0-prefer.json +++ b/Emby.Server.Implementations/Localization/Ratings/0-prefer.json @@ -24,7 +24,7 @@ } }, { - "ratingStrings": ["X", "XX", "XXX", "XXXX"], + "ratingStrings": ["X", "XX", "XXX", "XXXX", "P"], "ratingScore": { "score": 1000, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/ar.json b/Emby.Server.Implementations/Localization/Ratings/ar.json index 73dfd2c7c9..20053fe575 100644 --- a/Emby.Server.Implementations/Localization/Ratings/ar.json +++ b/Emby.Server.Implementations/Localization/Ratings/ar.json @@ -3,35 +3,49 @@ "supportsSubScores": false, "ratings": [ { - "ratingStrings": ["ATP"], + "ratingStrings": ["ATP", "Apta para todo público", "G"], "ratingScore": { "score": 0, "subScore": null } }, { - "ratingStrings": ["+13"], + "ratingStrings": ["SP"], + "ratingScore": { + "score": 10, + "subScore": null + } + }, + { + "ratingStrings": ["+13", "SAM 13", "SAM13", "R-13"], "ratingScore": { "score": 13, "subScore": null } }, { - "ratingStrings": ["+16"], + "ratingStrings": ["+16", "SAM 16", "SAM16"], "ratingScore": { "score": 16, "subScore": null } }, { - "ratingStrings": ["+18"], + "ratingStrings": ["R-17"], + "ratingScore": { + "score": 17, + "subScore": null + } + }, + { + "ratingStrings": ["+18", "SAM 18", "SAM18"], "ratingScore": { "score": 18, "subScore": null } }, { - "ratingStrings": ["C"], + "ratingStrings": ["C", "+18 C", "SAM 18 C", "SAM 18C", "SAM18C"], "ratingScore": { "score": 1001, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/au.json b/Emby.Server.Implementations/Localization/Ratings/au.json index a563df899d..c5d4c97443 100644 --- a/Emby.Server.Implementations/Localization/Ratings/au.json +++ b/Emby.Server.Implementations/Localization/Ratings/au.json @@ -52,7 +52,7 @@ } }, { - "ratingStrings": ["X", "X18", "X 18"], + "ratingStrings": ["X", "X18", "X 18", "X18+", "X 18+"], "ratingScore": { "score": 1000, "subScore": 0 diff --git a/Emby.Server.Implementations/Localization/Ratings/de.json b/Emby.Server.Implementations/Localization/Ratings/de.json index 30c34b230c..2a925f999f 100644 --- a/Emby.Server.Implementations/Localization/Ratings/de.json +++ b/Emby.Server.Implementations/Localization/Ratings/de.json @@ -3,35 +3,35 @@ "supportsSubScores": false, "ratings": [ { - "ratingStrings": ["0", "FSK 0", "FSK-0", "Educational", "Infoprogramm"], + "ratingStrings": ["0", "FSK 0", "FSK-0", "FSK0", "ab 0", "Educational", "Infoprogramm"], "ratingScore": { "score": 0, "subScore": null } }, { - "ratingStrings": ["6", "FSK 6", "FSK-6"], + "ratingStrings": ["6", "FSK 6", "FSK-6", "FSK6", "ab 6"], "ratingScore": { "score": 6, "subScore": null } }, { - "ratingStrings": ["12", "FSK 12", "FSK-12"], + "ratingStrings": ["12", "FSK 12", "FSK-12", "FSK12", "ab 12"], "ratingScore": { "score": 12, "subScore": null } }, { - "ratingStrings": ["16", "FSK 16", "FSK-16"], + "ratingStrings": ["16", "FSK 16", "FSK-16", "FSK16", "ab 16"], "ratingScore": { "score": 16, "subScore": null } }, { - "ratingStrings": ["18", "FSK 18", "FSK-18"], + "ratingStrings": ["18", "FSK 18", "FSK-18", "FSK18", "ab 18"], "ratingScore": { "score": 18, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/fr.json b/Emby.Server.Implementations/Localization/Ratings/fr.json index e8bafd6b87..333e467a08 100644 --- a/Emby.Server.Implementations/Localization/Ratings/fr.json +++ b/Emby.Server.Implementations/Localization/Ratings/fr.json @@ -24,14 +24,14 @@ } }, { - "ratingStrings": ["10"], + "ratingStrings": ["10", "Interdit aux moins de 10 ans"], "ratingScore": { "score": 10, "subScore": null } }, { - "ratingStrings": ["12"], + "ratingStrings": ["12", "–12", "Interdit aux moins de 12 ans"], "ratingScore": { "score": 12, "subScore": null @@ -45,14 +45,14 @@ } }, { - "ratingStrings": ["16"], + "ratingStrings": ["16", "–16", "Interdit aux moins de 16 ans"], "ratingScore": { "score": 16, "subScore": null } }, { - "ratingStrings": ["18"], + "ratingStrings": ["18", "–18", "Interdit aux moins de 18 ans"], "ratingScore": { "score": 18, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/id.json b/Emby.Server.Implementations/Localization/Ratings/id.json index 8c687c2320..f55e4e4333 100644 --- a/Emby.Server.Implementations/Localization/Ratings/id.json +++ b/Emby.Server.Implementations/Localization/Ratings/id.json @@ -3,14 +3,28 @@ "supportsSubScores": false, "ratings": [ { - "ratingStrings": ["SU"], + "ratingStrings": ["SU", "SU-BO", "Semua Umur"], "ratingScore": { "score": 0, "subScore": null } }, { - "ratingStrings": ["13+"], + "ratingStrings": ["P", "P2+", "P-BO", "Prasekolah"], + "ratingScore": { + "score": 2, + "subScore": null + } + }, + { + "ratingStrings": ["A", "A7+", "A-BO", "Anak"], + "ratingScore": { + "score": 7, + "subScore": null + } + }, + { + "ratingStrings": ["13+", "R", "R13+", "R-BO", "Remaja"], "ratingScore": { "score": 13, "subScore": null @@ -24,6 +38,13 @@ } }, { + "ratingStrings": ["D", "D18+", "Dewasa"], + "ratingScore": { + "score": 18, + "subScore": null + } + }, + { "ratingStrings": ["21+"], "ratingScore": { "score": 21, diff --git a/Emby.Server.Implementations/Localization/Ratings/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/Localization/Ratings/tw.json b/Emby.Server.Implementations/Localization/Ratings/tw.json index a7869c1221..9af7dd3128 100644 --- a/Emby.Server.Implementations/Localization/Ratings/tw.json +++ b/Emby.Server.Implementations/Localization/Ratings/tw.json @@ -3,35 +3,35 @@ "supportsSubScores": false, "ratings": [ { - "ratingStrings": ["0+"], + "ratingStrings": ["0+", "普遍級", "普"], "ratingScore": { "score": 0, "subScore": null } }, { - "ratingStrings": ["6+"], + "ratingStrings": ["6+", "保護級", "護"], "ratingScore": { "score": 6, "subScore": null } }, { - "ratingStrings": ["12+"], + "ratingStrings": ["12+", "輔12級", "輔導12歲級", "輔導級"], "ratingScore": { "score": 12, "subScore": null } }, { - "ratingStrings": ["15+"], + "ratingStrings": ["15+", "輔15級", "輔導15歲級"], "ratingScore": { "score": 15, "subScore": null } }, { - "ratingStrings": ["18+"], + "ratingStrings": ["18+", "限制級", "限"], "ratingScore": { "score": 18, "subScore": null diff --git a/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..dd71f843e8 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -846,7 +846,13 @@ namespace Emby.Server.Implementations.Session var data = _userDataManager.GetUserData(user, item); data.PlayCount++; - data.LastPlayedDate = DateTime.UtcNow; + + // Re-watching a played item only counts once a progress or stop report gets past the resume threshold, + // otherwise rewatch Next Up moves on from an episode that was barely started + if (!data.Played || !item.SupportsPositionTicksResume) + { + data.LastPlayedDate = DateTime.UtcNow; + } if (item.SupportsPlayedStatus && !item.SupportsPositionTicksResume) { @@ -972,7 +978,12 @@ namespace Emby.Server.Implementations.Session if (positionTicks.HasValue) { - _userDataManager.UpdatePlayState(item, data, positionTicks.Value); + var playedToCompletion = _userDataManager.UpdatePlayState(item, data, positionTicks.Value); + if (playedToCompletion || data.PlaybackPositionTicks > 0) + { + data.LastPlayedDate = DateTime.UtcNow; + } + changed = true; } @@ -1174,6 +1185,10 @@ namespace Emby.Server.Implementations.Session if (positionTicks.HasValue) { playedToCompletion = _userDataManager.UpdatePlayState(item, data, positionTicks.Value); + if (playedToCompletion || data.PlaybackPositionTicks > 0) + { + data.LastPlayedDate = DateTime.UtcNow; + } } else { @@ -1181,6 +1196,7 @@ namespace Emby.Server.Implementations.Session data.PlayCount++; data.Played = item.SupportsPlayedStatus; data.PlaybackPositionTicks = 0; + data.LastPlayedDate = DateTime.UtcNow; playedToCompletion = true; } @@ -1449,6 +1465,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 +1481,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/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs index 161479e4ca..2fa7b649af 100644 --- a/Jellyfin.Api/Controllers/ApiKeyController.cs +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.ApiKeyDtos; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Security; using MediaBrowser.Model.Querying; @@ -32,31 +34,31 @@ public class ApiKeyController : BaseJellyfinApiController /// Get all keys. /// </summary> /// <response code="200">Api keys retrieved.</response> - /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns> + /// <returns>A <see cref="QueryResult{AuthenticationInfoDto}"/> with all keys.</returns> [HttpGet("Keys")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys() + public async Task<ActionResult<QueryResult<AuthenticationInfoDto>>> GetKeys() { var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false); - return new QueryResult<AuthenticationInfo>(keys); + return new QueryResult<AuthenticationInfoDto>(keys.Select(ToDto).ToList()); } /// <summary> /// Create a new api key. /// </summary> /// <param name="app">Name of the app using the authentication key.</param> - /// <response code="204">Api key created.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="200">Api key created.</response> + /// <returns>The created api key.</returns> [HttpPost("Keys")] [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CreateKey([FromQuery, Required] string app) + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<AuthenticationInfoDto>> CreateKey([FromQuery, Required] string app) { - await _authenticationManager.CreateApiKey(app).ConfigureAwait(false); + var key = await _authenticationManager.CreateApiKey(app).ConfigureAwait(false); - return NoContent(); + return ToDto(key); } /// <summary> @@ -74,4 +76,21 @@ public class ApiKeyController : BaseJellyfinApiController return NoContent(); } + + private static AuthenticationInfoDto ToDto(AuthenticationInfo info) + => new() + { + Id = info.Id, + AccessToken = info.AccessToken, + DeviceId = info.DeviceId, + AppName = info.AppName, + AppVersion = info.AppVersion, + DeviceName = info.DeviceName, + UserId = info.UserId, + IsActive = info.IsActive, + DateCreated = info.DateCreated, + DateRevoked = info.DateRevoked, + DateLastActivity = info.DateLastActivity, + UserName = info.UserName + }; } diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 034a9dea55..6df76de0b4 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -1778,6 +1778,16 @@ public class DynamicHlsController : BaseJellyfinApiController } /// <summary> + /// Selects the HEVC Dolby Vision sample entry tag. + /// </summary> + /// <param name="stream">The video stream.</param> + /// <returns>The MP4 sample entry tag.</returns> + internal static string GetDolbyVisionHevcCodecTag(MediaStream stream) + { + return stream.DvProfile == 8 ? "hvc1" : "dvh1"; + } + + /// <summary> /// Gets the video arguments for transcoding. /// </summary> /// <param name="state">The <see cref="StreamState"/>.</param> @@ -1819,8 +1829,7 @@ public class DynamicHlsController : BaseJellyfinApiController { if (isActualOutputVideoCodecHevc) { - // Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari. - var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1"; + var codecTag = GetDolbyVisionHevcCodecTag(state.VideoStream); args += $" -tag:v:0 {codecTag} -strict -2"; } else if (isActualOutputVideoCodecAv1) @@ -1928,7 +1937,7 @@ public class DynamicHlsController : BaseJellyfinApiController { // Transcoding job is over, so assume all existing files are ready _logger.LogDebug("serving up {0} as transcode is over", segmentPath); - return GetSegmentResult(state, segmentPath, transcodingJob); + return GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob); } var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); @@ -1937,7 +1946,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (segmentIndex < currentTranscodingIndex) { _logger.LogDebug("serving up {0} as transcode index {1} is past requested point {2}", segmentPath, currentTranscodingIndex, segmentIndex); - return GetSegmentResult(state, segmentPath, transcodingJob); + return GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob); } } @@ -1953,7 +1962,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath)) { _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath); - return GetSegmentResult(state, segmentPath, transcodingJob); + return GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob); } } else @@ -1984,10 +1993,10 @@ public class DynamicHlsController : BaseJellyfinApiController _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath); } - return GetSegmentResult(state, segmentPath, transcodingJob); + return GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob); } - private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJob? transcodingJob) + private ActionResult GetSegmentResult(StreamState state, string segmentPath, int segmentIndex, TranscodingJob? transcodingJob) { var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks; @@ -1997,6 +2006,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (transcodingJob is not null) { transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks); + transcodingJob.ReportSegmentDownloaded(segmentIndex, segmentEndingPositionTicks); _transcodeManager.OnTranscodeEndRequest(transcodingJob); } 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/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 65fffc4181..288efebc4f 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -292,6 +292,8 @@ public class ItemUpdateController : BaseJellyfinApiController item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; item.ProductionYear = request.ProductionYear; + var previousOfficialRating = item.OfficialRating; + var previousCustomRating = item.CustomRating; request.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating; item.OfficialRating = request.OfficialRating; item.CustomRating = request.CustomRating; @@ -318,12 +320,14 @@ public class ItemUpdateController : BaseJellyfinApiController { season.SeriesName = rseries.Name; + var previousSeasonOfficialRating = season.OfficialRating; + var previousSeasonCustomRating = season.CustomRating; if (!season.LockedFields.Contains(MetadataField.OfficialRating)) { - season.OfficialRating = request.OfficialRating; + season.OfficialRating = GetPropagatedRating(season.OfficialRating, previousOfficialRating, request.OfficialRating); } - season.CustomRating = request.CustomRating; + season.CustomRating = GetPropagatedRating(season.CustomRating, previousCustomRating, request.CustomRating); if (!season.LockedFields.Contains(MetadataField.Tags)) { @@ -339,10 +343,10 @@ public class ItemUpdateController : BaseJellyfinApiController if (!ep.LockedFields.Contains(MetadataField.OfficialRating)) { - ep.OfficialRating = request.OfficialRating; + ep.OfficialRating = GetPropagatedRating(ep.OfficialRating, previousSeasonOfficialRating, season.OfficialRating); } - ep.CustomRating = request.CustomRating; + ep.CustomRating = GetPropagatedRating(ep.CustomRating, previousSeasonCustomRating, season.CustomRating); if (!ep.LockedFields.Contains(MetadataField.Tags)) { @@ -358,12 +362,14 @@ public class ItemUpdateController : BaseJellyfinApiController { foreach (var ep in season.Children.OfType<Episode>()) { + ep.SeasonName = season.Name; + if (!ep.LockedFields.Contains(MetadataField.OfficialRating)) { - ep.OfficialRating = request.OfficialRating; + ep.OfficialRating = GetPropagatedRating(ep.OfficialRating, previousOfficialRating, request.OfficialRating); } - ep.CustomRating = request.CustomRating; + ep.CustomRating = GetPropagatedRating(ep.CustomRating, previousCustomRating, request.CustomRating); if (!ep.LockedFields.Contains(MetadataField.Tags)) { @@ -380,10 +386,10 @@ public class ItemUpdateController : BaseJellyfinApiController { if (!track.LockedFields.Contains(MetadataField.OfficialRating)) { - track.OfficialRating = request.OfficialRating; + track.OfficialRating = GetPropagatedRating(track.OfficialRating, previousOfficialRating, request.OfficialRating); } - track.CustomRating = request.CustomRating; + track.CustomRating = GetPropagatedRating(track.CustomRating, previousCustomRating, request.CustomRating); if (!track.LockedFields.Contains(MetadataField.Tags)) { @@ -475,6 +481,23 @@ public class ItemUpdateController : BaseJellyfinApiController } } + /// <summary> + /// Gets the rating a child should carry after its parent's rating changed from <paramref name="previousRating"/> + /// to <paramref name="newRating"/>: a child with no rating, or the parent's previous one, follows the parent. + /// A child with a rating of its own, or any child when the parent's rating did not change, keeps its rating. + /// </summary> + private static string? GetPropagatedRating(string? childRating, string? previousRating, string? newRating) + { + if (string.Equals(previousRating ?? string.Empty, newRating ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return childRating; + } + + return string.IsNullOrEmpty(childRating) || string.Equals(childRating, previousRating, StringComparison.OrdinalIgnoreCase) + ? newRating + : childRating; + } + private SeriesStatus? GetSeriesStatus(BaseItemDto item) { if (string.IsNullOrEmpty(item.Status)) diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 65bfe25d21..9e6bfde085 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -10,6 +10,10 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryStructureDto; +using Jellyfin.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Api; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; @@ -36,6 +40,7 @@ public class LibraryStructureController : BaseJellyfinApiController private readonly ILibraryManager _libraryManager; private readonly ILibraryMonitor _libraryMonitor; private readonly IDirectoryService _directoryService; + private readonly IUserManager _userManager; /// <summary> /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. @@ -44,16 +49,19 @@ public class LibraryStructureController : BaseJellyfinApiController /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> /// <param name="directoryService">Instance of <see cref="IDirectoryService"/> interface.</param> + /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> public LibraryStructureController( IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, - IDirectoryService directoryService) + IDirectoryService directoryService, + IUserManager userManager) { _appPaths = serverConfigurationManager.ApplicationPaths; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; _directoryService = directoryService; + _userManager = userManager; } /// <summary> @@ -138,7 +146,7 @@ public class LibraryStructureController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] - public ActionResult RenameVirtualFolder( + public async Task<ActionResult> RenameVirtualFolder( [FromQuery] string? name, [FromQuery] string? newName, [FromQuery] bool refreshLibrary = false) @@ -173,6 +181,12 @@ public class LibraryStructureController : BaseJellyfinApiController return Conflict($"The media library already exists at {newPath}."); } + var oldLibrary = FindLibrary(currentPath); + // Use the stored ID when possible. The case sensitivity setting may have changed since the library was created. + var oldLibraryId = oldLibrary?.Id ?? _libraryManager.GetNewItemId(currentPath, typeof(CollectionFolder)); + var oldLibraryName = oldLibrary?.Name ?? Path.GetFileName(currentPath); + var newLibraryId = _libraryManager.GetNewItemId(newPath, typeof(CollectionFolder)); + _libraryMonitor.Stop(); try @@ -188,47 +202,155 @@ public class LibraryStructureController : BaseJellyfinApiController } _directoryService.Move(currentPath, newPath); + + await ReplaceLibraryIdInUserPreferencesAsync( + oldLibraryId, + newLibraryId, + oldLibraryName, + Path.GetFileName(newPath), + oldLibrary?.CollectionType).ConfigureAwait(false); } finally { CollectionFolder.OnCollectionFolderChange(); - Task.Run(async () => + _ = Task.Run(() => RefreshAfterRenameAsync(newPath, refreshLibrary)); + } + + return NoContent(); + } + + private async Task RefreshAfterRenameAsync(string newPath, bool refreshLibrary) + { + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); + var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase)); + if (newLib is CollectionFolder folder) { - // No need to start if scanning the library because it will handle it - if (refreshLibrary) + _libraryManager.ClearIgnoreRuleCache(); + foreach (var child in folder.GetPhysicalFolders()) { - await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); - var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase)); - if (newLib is CollectionFolder folder) - { - _libraryManager.ClearIgnoreRuleCache(); - foreach (var child in folder.GetPhysicalFolders()) - { - await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false); - await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); - } - } - else - { - _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.ClearIgnoreRuleCache(); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - // Have to block here to allow exceptions to bubble - await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitor.Start(); + await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false); + await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } - }); + } + else + { + _libraryManager.ClearIgnoreRuleCache(); + // We don't know if this one can be validated individually, trigger a new validation + _libraryManager.QueueLibraryScan(); + } + + _libraryManager.ClearIgnoreRuleCache(); } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + } - return NoContent(); + private CollectionFolder? FindLibrary(string path) + => _libraryManager.GetUserRootFolder().Children + .OfType<CollectionFolder>() + .FirstOrDefault(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase)); + + private async Task ReplaceLibraryIdInUserPreferencesAsync( + Guid oldId, + Guid newId, + string oldName, + string newName, + CollectionType? viewType) + { + var shadowView = ( + Old: _libraryManager.GetShadowViewId(oldName, oldId, viewType), + New: _libraryManager.GetShadowViewId(newName, newId, viewType)); + + if (oldId.Equals(newId) && shadowView.Old.Equals(shadowView.New)) + { + return; + } + + PreferenceKind[] libraryPreferences = + [ + PreferenceKind.EnabledFolders, + PreferenceKind.BlockedMediaFolders, + PreferenceKind.EnableContentDeletionFromFolders, + PreferenceKind.LatestItemExcludes, + PreferenceKind.MyMediaExcludes, + PreferenceKind.GroupedFolders, + PreferenceKind.OrderedViews + ]; + + // These lists hold what the "My Media" row shows, which includes the views built from a library. + PreferenceKind[] viewPreferences = [PreferenceKind.MyMediaExcludes, PreferenceKind.OrderedViews]; + + foreach (var user in _userManager.GetUsers()) + { + var libraryMap = new Dictionary<Guid, Guid> { [oldId] = newId }; + var viewMap = new Dictionary<Guid, Guid>(libraryMap) + { + [shadowView.Old] = shadowView.New, + [_libraryManager.GetNamedViewId(user, oldId, viewType)] = _libraryManager.GetNamedViewId(user, newId, viewType) + }; + + var changed = false; + + foreach (var kind in libraryPreferences) + { + changed |= ReplaceIds(user, kind, viewPreferences.Contains(kind) ? viewMap : libraryMap); + } + + if (changed) + { + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + } + } + } + + private static bool ReplaceIds(User user, PreferenceKind kind, Dictionary<Guid, Guid> map) + { + var values = user.GetPreference(kind); + var rewritten = new List<string>(values.Length); + var seen = new HashSet<Guid>(); + var touched = false; + + foreach (var value in values) + { + // Clients write these in both the dashed and the plain form, so compare them parsed. + if (!Guid.TryParse(value, out var parsed)) + { + rewritten.Add(value); + continue; + } + + var isReplaced = map.TryGetValue(parsed, out var replacement) && !replacement.Equals(parsed); + if (isReplaced) + { + parsed = replacement; + touched = true; + } + + if (!seen.Add(parsed)) + { + touched = true; + continue; + } + + var format = value.Contains('-', StringComparison.Ordinal) ? "D" : "N"; + rewritten.Add(isReplaced ? parsed.ToString(format, CultureInfo.InvariantCulture) : value); + } + + if (touched) + { + user.SetPreference(kind, rewritten.ToArray()); + } + + return touched; } /// <summary> @@ -260,7 +382,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 +449,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/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index c4851091c1..6721cd6c7e 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -233,18 +233,17 @@ public class SubtitleController : BaseJellyfinApiController format = "json"; } - if (string.IsNullOrEmpty(format)) + // Graphical subtitles cannot be parsed or rewritten, so they are always delivered as the raw + // stream for the client to render. Serve them straight from disk instead of piping them + // through the encoder, so that range requests keep working: clients fetch these tracks + // incrementally while rendering them, and they are far too large to hand over in one piece. + if (MediaStream.IsPgsFormat(format) || MediaStream.IsVobSubFormat(format)) { - var item = _libraryManager.GetItemById<Video>(itemId.Value); - - var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); - var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) - .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); - - var subtitleStream = mediaSource.MediaStreams - .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); - - return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path)); + var subtitlePath = await GetGraphicalSubtitlePath(itemId.Value, mediaSourceId, index.Value).ConfigureAwait(false); + if (!string.IsNullOrEmpty(subtitlePath)) + { + return PhysicalFile(subtitlePath, MimeTypes.GetMimeType(subtitlePath), true); + } } if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) @@ -456,6 +455,46 @@ public class SubtitleController : BaseJellyfinApiController } } + private async Task<string?> GetGraphicalSubtitlePath(Guid id, string? mediaSourceId, int index) + { + var item = _libraryManager.GetItemById<BaseItem>(id); + if (item is null) + { + return null; + } + + var idString = id.ToString("N", CultureInfo.InvariantCulture); + var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) + .FirstOrDefault(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.OrdinalIgnoreCase)); + + var subtitleStream = mediaSource?.MediaStreams + .FirstOrDefault(i => i.Type == MediaStreamType.Subtitle && i.Index == index); + + if (mediaSource is null || subtitleStream is null + || !(subtitleStream.IsPgsSubtitleStream || subtitleStream.IsVobSubSubtitleStream)) + { + return null; + } + + var path = await _subtitleEncoder.GetSubtitleFilePath(subtitleStream, mediaSource, CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrEmpty(path)) + { + return null; + } + + // Only a raw PGS payload or an extracted .mks can be handed over untouched. Anything else the + // encoder resolved to (e.g. a VobSub .idx/.sub pair, which it converts) is not a passthrough. + var extension = Path.GetExtension(path.AsSpan()).TrimStart('.').ToString(); + if (!MediaStream.IsPgsFormat(extension) + && !string.Equals(extension, "mks", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Remotely hosted external subtitles have no local file to serve; those fall back to the encoder. + return _fileSystem.FileExists(path) ? path : null; + } + /// <summary> /// Encodes a subtitle in the specified format. /// </summary> diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index 8b359c48af..a934e5dbf8 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -90,7 +90,7 @@ public class UserViewsController : BaseJellyfinApiController var dtoOptions = new DtoOptions(); dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId]; - var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user)); + var dtos = _dtoService.GetBaseItemDtos(folders, dtoOptions, user, skipVisibilityCheck: true); return new QueryResult<BaseItemDto>(dtos); } diff --git a/Jellyfin.Api/Models/ApiKeyDtos/AuthenticationInfoDto.cs b/Jellyfin.Api/Models/ApiKeyDtos/AuthenticationInfoDto.cs new file mode 100644 index 0000000000..945fded532 --- /dev/null +++ b/Jellyfin.Api/Models/ApiKeyDtos/AuthenticationInfoDto.cs @@ -0,0 +1,69 @@ +using System; + +namespace Jellyfin.Api.Models.ApiKeyDtos; + +/// <summary> +/// An API key. +/// </summary> +public class AuthenticationInfoDto +{ + /// <summary> + /// Gets or sets the identifier. + /// </summary> + public long Id { get; set; } + + /// <summary> + /// Gets or sets the access token. + /// </summary> + public string? AccessToken { get; set; } + + /// <summary> + /// Gets or sets the device identifier. + /// </summary> + public string? DeviceId { get; set; } + + /// <summary> + /// Gets or sets the name of the app using the key. + /// </summary> + public string? AppName { get; set; } + + /// <summary> + /// Gets or sets the application version. + /// </summary> + public string? AppVersion { get; set; } + + /// <summary> + /// Gets or sets the name of the device. + /// </summary> + public string? DeviceName { get; set; } + + /// <summary> + /// Gets or sets the user identifier. + /// </summary> + public Guid UserId { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this instance is active. + /// </summary> + public bool IsActive { get; set; } + + /// <summary> + /// Gets or sets the date the key was created. + /// </summary> + public DateTime DateCreated { get; set; } + + /// <summary> + /// Gets or sets the date the key was revoked. + /// </summary> + public DateTime? DateRevoked { get; set; } + + /// <summary> + /// Gets or sets the date of the last activity. + /// </summary> + public DateTime DateLastActivity { get; set; } + + /// <summary> + /// Gets or sets the user name. + /// </summary> + public string? UserName { get; set; } +} 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..4e5d561f0a 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(); @@ -183,6 +212,7 @@ public sealed partial class BaseItemRepository IsFavoriteOrLiked = filter.IsFavoriteOrLiked, IsLiked = filter.IsLiked, IsLocked = filter.IsLocked, + ImageTypes = filter.ImageTypes, NameLessThan = filter.NameLessThan, NameStartsWith = filter.NameStartsWith, NameStartsWithOrGreater = filter.NameStartsWithOrGreater, @@ -194,7 +224,12 @@ public sealed partial class BaseItemRepository Years = filter.Years, NameContains = filter.NameContains, SearchTerm = filter.SearchTerm, - ExcludeItemIds = filter.ExcludeItemIds + ExcludeItemIds = filter.ExcludeItemIds, + + // A genre, studio or artist carries none of the tags of the media it describes, so an + // allow list can only ever hide all of them. Reachability is settled by innerQueryFilter + // instead: a value gets this far only when an item the user may see carries it. + IncludeInheritedTags = [] }; // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) into one diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index c0067d8392..4cd02bcd8d 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> @@ -605,11 +612,12 @@ public sealed partial class BaseItemRepository var blockedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var blockedByAncestor = ItemsBelowTaggedAncestor(context, blockedTagItemIds); baseQuery = baseQuery.Where(e => !blockedTagItemIds.Contains(e.Id) && !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value)) - && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId)) + && !blockedByAncestor.Contains(e.Id) && !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value))); } @@ -622,10 +630,12 @@ public sealed partial class BaseItemRepository .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var allowedByAncestor = ItemsBelowTaggedAncestor(context, allowedTagItemIds); + baseQuery = baseQuery.Where(e => allowedTagItemIds.Contains(e.Id) || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) - || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) + || allowedByAncestor.Contains(e.Id) || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) // People don't carry the tags of the media they appear in and would never match @@ -636,6 +646,17 @@ public sealed partial class BaseItemRepository } /// <summary> + /// Reads back the items that carry one of the tagged items as an ancestor. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="taggedItemIds">The ids of the items carrying the tag.</param> + /// <returns>The ids of the items below one of them.</returns> + private static IQueryable<Guid> ItemsBelowTaggedAncestor(JellyfinDbContext context, IQueryable<Guid> taggedItemIds) + => context.AncestorIds + .Where(a => taggedItemIds.Contains(a.ParentItemId)) + .Select(a => a.ItemId); + + /// <summary> /// Builds a filter expression for max parental rating that handles both rated items /// and unrated BoxSets/Playlists (which check linked children's ratings). /// </summary> @@ -645,24 +666,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..e66f18bc6e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -476,16 +476,8 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.Name)) { - if (filter.UseRawName == true) - { - var nameLower = filter.Name.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.Name!.ToLower() == nameLower); - } - else - { - var cleanName = filter.Name.GetCleanValue(); - baseQuery = baseQuery.Where(e => e.CleanName == cleanName); - } + var cleanName = filter.Name.GetCleanValue(); + baseQuery = baseQuery.Where(e => e.CleanName == cleanName); } var nameContains = filter.NameContains; @@ -581,8 +573,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 +799,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) @@ -1135,11 +1132,12 @@ public sealed partial class BaseItemRepository var blockedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var blockedByAncestor = ItemsBelowTaggedAncestor(context, blockedTagItemIds); baseQuery = baseQuery.Where(e => !blockedTagItemIds.Contains(e.Id) && !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value)) - && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId)) + && !blockedByAncestor.Contains(e.Id) && !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value))); } @@ -1151,11 +1149,12 @@ public sealed partial class BaseItemRepository var allowedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var allowedByAncestor = ItemsBelowTaggedAncestor(context, allowedTagItemIds); baseQuery = baseQuery.Where(e => allowedTagItemIds.Contains(e.Id) || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) - || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) + || allowedByAncestor.Contains(e.Id) || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) // People don't carry the tags of the media they appear in and would never match 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..3bf33b969e 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(); @@ -132,17 +141,17 @@ public class ItemPersistenceService : IItemPersistenceService context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete(); 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.ItemValues.Where(e => !e.BaseItemsMap!.Any()).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(); @@ -221,17 +230,18 @@ public class ItemPersistenceService : IItemPersistenceService var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); await using (transaction.ConfigureAwait(false)) { - var userKeys = item.GetUserDataKeys().ToArray(); - var retentionDate = (DateTime?)null; + var userKeys = item.GetUserDataKeys().Distinct().ToList(); - await dbContext.UserData + var detached = await dbContext.UserData .Where(e => e.ItemId == BaseItemRepository.PlaceholderId) .Where(e => userKeys.Contains(e.CustomDataKey)) - .ExecuteUpdateAsync( - e => e - .SetProperty(f => f.ItemId, item.Id) - .SetProperty(f => f.RetentionDate, retentionDate), - cancellationToken).ConfigureAwait(false); + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (detached.Count > 0) + { + await ReconcileUserDataAsync(dbContext, item, userKeys, detached, cancellationToken).ConfigureAwait(false); + } item.UserData = await dbContext.UserData .AsNoTracking() @@ -244,6 +254,62 @@ public class ItemPersistenceService : IItemPersistenceService } } + private static async Task ReconcileUserDataAsync( + JellyfinDbContext dbContext, + BaseItemDto item, + IReadOnlyList<string> userKeys, + List<UserData> detached, + CancellationToken cancellationToken) + { + var existing = await dbContext.UserData + .Where(e => e.ItemId == item.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + // Keys the item no longer reports are carried over: they may only be missing mid-refresh. + var winners = detached.Concat(existing) + .GroupBy(e => e.UserId) + .Select(g => ( + Winner: g + .OrderByDescending(e => e.LastPlayedDate) + .ThenByDescending(e => e.PlayCount) + .ThenByDescending(e => e.PlaybackPositionTicks) + .First(), + Keys: userKeys.Union(g.Select(e => e.CustomDataKey)).ToList())) + .ToList(); + + dbContext.UserData.RemoveRange(detached); + dbContext.UserData.RemoveRange(existing); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + foreach (var (winner, keys) in winners) + { + foreach (var key in keys) + { + dbContext.UserData.Add(new UserData + { + ItemId = item.Id, + Item = null, + UserId = winner.UserId, + User = null, + CustomDataKey = key, + RetentionDate = null, + AudioStreamIndex = winner.AudioStreamIndex, + IsFavorite = winner.IsFavorite, + LastPlayedDate = winner.LastPlayedDate, + Likes = winner.Likes, + PlaybackPositionTicks = winner.PlaybackPositionTicks, + PlayCount = winner.PlayCount, + Played = winner.Played, + Rating = winner.Rating, + SubtitleStreamIndex = winner.SubtitleStreamIndex + }); + } + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + private void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(items); @@ -268,7 +334,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 +394,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 +723,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..802138ed5e 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 { @@ -381,6 +423,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I .Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId))); } + if (filter.MustHaveItem) + { + // A credit with no by-name item behind it cannot be handed back as one. + var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + query = query.Where(e => context.BaseItems.Any(b => b.Type == personType && b.Name == e.Name)); + } + if (!filter.ItemId.IsEmpty()) { var itemId = filter.ItemId; @@ -435,12 +484,12 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (!string.IsNullOrWhiteSpace(filter.NameLessThan)) { - query = query.Where(e => e.Name.CompareTo(filter.NameLessThan.ToLowerInvariant()) < 0); + query = query.Where(e => e.Name.ToLower().CompareTo(filter.NameLessThan.ToLowerInvariant()) < 0); } if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) { - query = query.Where(e => e.Name.CompareTo(filter.NameStartsWithOrGreater.ToLowerInvariant()) >= 0); + query = query.Where(e => e.Name.ToLower().CompareTo(filter.NameStartsWithOrGreater.ToLowerInvariant()) >= 0); } return query; diff --git a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs index cf0293463f..2bc7b9f915 100644 --- a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs +++ b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs @@ -23,14 +23,25 @@ namespace Jellyfin.Server.Implementations.Security } /// <inheritdoc /> - public async Task CreateApiKey(string name) + public async Task<AuthenticationInfo> CreateApiKey(string name) { var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - dbContext.ApiKeys.Add(new ApiKey(name)); + var apiKey = new ApiKey(name); + dbContext.ApiKeys.Add(apiKey); await dbContext.SaveChangesAsync().ConfigureAwait(false); + + return new AuthenticationInfo + { + AppName = apiKey.Name, + AccessToken = apiKey.AccessToken, + DateCreated = apiKey.DateCreated, + DeviceId = string.Empty, + DeviceName = string.Empty, + AppVersion = string.Empty + }; } } diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index 58b9f7f822..3c1164bffe 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -11,6 +11,7 @@ using AsyncKeyedLock; using J2N.Collections.Generic.Extensions; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; @@ -478,6 +479,14 @@ public partial class TrickplayManager : ITrickplayManager var mediaStream = mediaSource.VideoStream; var container = mediaSource.Container; + // Checks for write permission before generating images + if (saveWithMedia) + { + _logger.LogDebug("Verifying write permission in media directory {OutputDir}", outputDir); + Directory.CreateDirectory(outputDir.FullName); + FileHelper.CreateEmpty(Path.Combine(outputDir.FullName, ".jellyfin-trickplay")); + } + _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWidth, mediaPath, video.Id); imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated( mediaPath, @@ -722,7 +731,10 @@ public partial class TrickplayManager : ITrickplayManager public async Task DeleteTrickplayDataAsync(Guid itemId, CancellationToken cancellationToken) { var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - await dbContext.TrickplayInfos.Where(i => i.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + await dbContext.TrickplayInfos.Where(i => i.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } } /// <inheritdoc /> 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/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs index 0e126fe9a0..dd3c63ae32 100644 --- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs +++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs @@ -13,6 +13,8 @@ namespace Jellyfin.Server.Implementations.Users; /// </summary> public sealed class DisplayPreferencesManager : IDisplayPreferencesManager { + private const int MaxSaveAttempts = 3; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; /// <summary> @@ -28,17 +30,31 @@ public sealed class DisplayPreferencesManager : IDisplayPreferencesManager public DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client) { using var dbContext = _dbContextFactory.CreateDbContext(); - var prefs = dbContext.DisplayPreferences - .Include(pref => pref.HomeSections) - .FirstOrDefault(pref => - pref.UserId.Equals(userId) && pref.Client == client && pref.ItemId.Equals(itemId)); + var prefs = FindDisplayPreferences(dbContext, userId, itemId, client); + if (prefs is not null) + { + return prefs; + } - if (prefs is null) + prefs = new DisplayPreferences(userId, itemId, client); + dbContext.DisplayPreferences.Add(prefs); + try { - prefs = new DisplayPreferences(userId, itemId, client); - dbContext.DisplayPreferences.Add(prefs); dbContext.SaveChanges(); } + catch (DbUpdateException) + { + // Another request may have stored the preferences between the lookup and the insert, and the unique index + // rejected this one. Return the stored preferences; if there are none, the insert failed for another reason. + using var retryContext = _dbContextFactory.CreateDbContext(); + var stored = FindDisplayPreferences(retryContext, userId, itemId, client); + if (stored is null) + { + throw; + } + + return stored; + } return prefs; } @@ -52,7 +68,7 @@ public sealed class DisplayPreferencesManager : IDisplayPreferencesManager if (prefs is null) { - prefs = new ItemDisplayPreferences(userId, Guid.Empty, client); + prefs = new ItemDisplayPreferences(userId, itemId, client); dbContext.ItemDisplayPreferences.Add(prefs); dbContext.SaveChanges(); } @@ -83,19 +99,35 @@ public sealed class DisplayPreferencesManager : IDisplayPreferencesManager /// <inheritdoc /> public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences) { - using var dbContext = _dbContextFactory.CreateDbContext(); - dbContext.CustomItemDisplayPreferences.Where(prefs => prefs.UserId.Equals(userId) - && prefs.ItemId.Equals(itemId) - && prefs.Client == client) - .ExecuteDelete(); - - foreach (var (key, value) in customPreferences) + // Another request can store one of these keys after this one's delete, and the unique index then rejects the + // insert. Replacing the set again gives the same result, so the replace is repeated. + for (var attempt = 1; ; attempt++) { - dbContext.CustomItemDisplayPreferences - .Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value)); + using var dbContext = _dbContextFactory.CreateDbContext(); + using var transaction = dbContext.Database.BeginTransaction(); + dbContext.CustomItemDisplayPreferences.Where(prefs => prefs.UserId.Equals(userId) + && prefs.ItemId.Equals(itemId) + && prefs.Client == client) + .ExecuteDelete(); + + foreach (var (key, value) in customPreferences) + { + dbContext.CustomItemDisplayPreferences + .Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value)); + } + + try + { + dbContext.SaveChanges(); + } + catch (DbUpdateException) when (attempt < MaxSaveAttempts) + { + continue; + } + + transaction.Commit(); + return; } - - dbContext.SaveChanges(); } /// <inheritdoc/> @@ -113,4 +145,9 @@ public sealed class DisplayPreferencesManager : IDisplayPreferencesManager dbContext.ItemDisplayPreferences.Attach(itemDisplayPreferences).State = EntityState.Modified; dbContext.SaveChanges(); } + + private static DisplayPreferences? FindDisplayPreferences(JellyfinDbContext dbContext, Guid userId, Guid itemId, string client) + => dbContext.DisplayPreferences + .Include(pref => pref.HomeSections) + .FirstOrDefault(pref => pref.UserId.Equals(userId) && pref.Client == client && pref.ItemId.Equals(itemId)); } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index fea6084267..b15f6b98b2 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -847,14 +847,16 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) { + User user; using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var user = UserQuery(dbContext) + user = await UserQuery(dbContext) .AsTracking() - .FirstOrDefault(u => u.Id.Equals(userId)) + .FirstOrDefaultAsync(u => u.Id.Equals(userId)) + .ConfigureAwait(false) ?? throw new ArgumentException("No user exists with given Id!"); // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" @@ -919,6 +921,10 @@ namespace Jellyfin.Server.Implementations.Users await dbContext.SaveChangesAsync().ConfigureAwait(false); } } + + var eventArgs = new UserUpdatedEventArgs(user); + await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); + OnUserUpdated?.Invoke(this, eventArgs); } /// <inheritdoc/> diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index a5b6bc4604..21ffab8dd6 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -132,6 +132,14 @@ internal class JellyfinMigrationService } else { + // Only a media server start is refused. A MigrateSystem run is left to the migrations, which another database + // provider may rely on to initialise its database, and a restore replaces the database later, in StartServer. + var startupMode = startupOptions.StartupMode ?? Configuration.StartupMode.MediaServer; + if (startupMode == Configuration.StartupMode.MediaServer && string.IsNullOrWhiteSpace(startupOptions.RestoreArchive)) + { + await EnsureExistingDatabaseAsync(appPaths, logger).ConfigureAwait(false); + } + // migrate any existing migration.xml files var migrationConfigPath = Path.Join(appPaths.ConfigurationDirectoryPath, "migrations.xml"); var migrationOptions = File.Exists(migrationConfigPath) @@ -184,6 +192,54 @@ internal class JellyfinMigrationService } /// <summary> + /// Stops the startup of a server that has been set up before but whose database is missing or empty. Running the migrations + /// against such a database fails part way through, and seeding it would leave a server nobody can log in to. + /// </summary> + private async Task EnsureExistingDatabaseAsync(IApplicationPaths appPaths, ILogger logger) + { + string? problem = null; + var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator + ?? throw new InvalidOperationException("Jellyfin does only support relational databases."); + + // Check existence first: opening a connection to a missing SQLite database creates an empty file. + if (!await databaseCreator.ExistsAsync().ConfigureAwait(false)) + { + problem = "the database does not exist"; + } + else + { + var historyRepository = dbContext.GetService<IHistoryRepository>(); + if (!await historyRepository.ExistsAsync().ConfigureAwait(false)) + { + problem = "the database has no migration history"; + } + else if ((await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false)).Count == 0) + { + problem = "the migration history of the database is empty"; + } + } + } + + if (problem is null) + { + return; + } + + var message = string.Format( + CultureInfo.InvariantCulture, + "This server has been set up before (IsStartupWizardCompleted is true in {0}), but {1}. Jellyfin will not start an existing server with an empty database. " + + "To continue, either restore the previous database; or start over and keep this server's settings by setting IsStartupWizardCompleted to false in {0} " + + "(users, watch history and everything else stored in the database will not come back); or set up a new server with empty configuration and data directories.", + appPaths.SystemConfigurationFilePath, + problem); + logger.LogCritical("{Message}", message); + throw new InvalidOperationException(message); + } + + /// <summary> /// Runs all pending migrations of the requested stage. /// </summary> /// <param name="stage">The stage to migrate.</param> diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs index 0141b43c96..a5006c9bfa 100644 --- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs @@ -79,8 +79,8 @@ public class MigrateEncodingOptions : IMigrationRoutine tonemappingRange = parsedTonemappingRange; } - var encoderPreset = EncoderPreset.superfast; - if (Enum.TryParse<EncoderPreset>(oldConfig.TonemappingRange, true, out var parsedEncoderPreset)) + var encoderPreset = EncoderPreset.auto; + if (Enum.TryParse<EncoderPreset>(oldConfig.EncoderPreset, true, out var parsedEncoderPreset)) { encoderPreset = parsedEncoderPreset; } diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs index 8f239007d8..7fa555f005 100644 --- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs @@ -9,6 +9,7 @@ using Jellyfin.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; @@ -26,6 +27,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine private const int FileCheckProgressLogStep = 10_000; private const int ResolveProgressLogStep = 10_000; private const int DeleteProgressLogStep = 25; + private const string CollectionFolderTypeName = "MediaBrowser.Controller.Entities.CollectionFolder"; private readonly ILogger<MigrateLinkedChildren> _logger; private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; @@ -415,8 +417,18 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // An item outside every library location is normally left over from a removed media path, but // it looks exactly the same as one whose storage failed to mount (a wrong bind mount on the // first container start, for example). Only act on it while every location is readable. - var canRemoveUnrootedItems = inaccessiblePaths.Count == 0; + // Resolving no locations at all is the same hazard with none of the evidence: it cannot be + // told apart from an install that has libraries the server has not read yet, and acting on it + // means every file-backed item in the database is outside every location. + var canRemoveUnrootedItems = inaccessiblePaths.Count == 0 && allLibraryPaths.Count > 0; var skippedUnrootedItems = 0; + var removedUnrootedItems = 0; + + // One library losing its definition folder is the same accident confined to that library: it + // contributes no locations, so everything it held reads as unrooted while the other libraries + // still look healthy enough to act on. + var itemsInMissingLibraries = GetItemsInMissingLibraries(context, virtualFolders); + var skippedMissingLibraryItems = 0; var staleIds = new List<Guid>(); var checkedCount = 0; @@ -459,10 +471,16 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { // Item is not under ANY library location (accessible or not) — // it's orphaned from all libraries (e.g. media path was removed from config) - if (canRemoveUnrootedItems) + if (itemsInMissingLibraries.Contains(item.Id)) + { + // Its library is the thing that went missing, not its media path. + skippedMissingLibraryItems++; + } + else if (canRemoveUnrootedItems) { _logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, path); staleIds.Add(item.Id); + removedUnrootedItems++; } else { @@ -473,12 +491,29 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // Otherwise: item is under an inaccessible location — skip (storage may be offline) } - if (skippedUnrootedItems > 0) + if (skippedMissingLibraryItems > 0) { _logger.LogWarning( - "Keeping {Count} items that are outside every library location because {LocationCount} library location(s) are currently unavailable.", - skippedUnrootedItems, - inaccessiblePaths.Count); + "Keeping {Count} items whose library definition is missing from {Path}. Restore the definition or recreate the library; the items are kept meanwhile.", + skippedMissingLibraryItems, + _appPaths.DefaultUserViewsPath); + } + + if (skippedUnrootedItems > 0) + { + if (allLibraryPaths.Count == 0) + { + _logger.LogWarning( + "Keeping {Count} items that are outside every library location because this server resolved no library locations at all.", + skippedUnrootedItems); + } + else + { + _logger.LogWarning( + "Keeping {Count} items that are outside every library location because {LocationCount} library location(s) are currently unavailable.", + skippedUnrootedItems, + inaccessiblePaths.Count); + } } if (staleIds.Count == 0) @@ -487,13 +522,50 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine return; } - _logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count); + // Whether an item went because its own file is gone or because nothing claims its path is the + // difference between a handful of deletions and a whole library, so report the split. + _logger.LogInformation( + "Found {Count} stale items to remove, {UnrootedCount} of them because their path is outside every library location.", + staleIds.Count, + removedUnrootedItems); var deleted = ResolveAndDeleteItems(staleIds, "items with missing files"); _logger.LogInformation("Removed {Count} stale items.", deleted); } + // Items under a library whose definition folder is gone. Their locations cannot be resolved, so + // they read as unrooted, but the library is what went missing and the media path is still theirs. + private HashSet<Guid> GetItemsInMissingLibraries(JellyfinDbContext context, IReadOnlyList<VirtualFolderInfo> virtualFolders) + { + var liveLibraryPaths = virtualFolders + .Select(folder => Path.Combine(_appPaths.DefaultUserViewsPath, folder.Name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var missingLibraryIds = context.BaseItems + .Where(b => b.Type == CollectionFolderTypeName && b.Path != null) + .Select(b => new { b.Id, b.Path }) + .ToList() + .Where(b => !liveLibraryPaths.Contains(_appHost.ExpandVirtualPath(b.Path!))) + .Select(b => b.Id) + .ToList(); + + if (missingLibraryIds.Count == 0) + { + return []; + } + + _logger.LogWarning( + "{Count} library definition(s) known to the database are not in {Path}.", + missingLibraryIds.Count, + _appPaths.DefaultUserViewsPath); + + return context.AncestorIds + .WhereOneOrMany(missingLibraryIds, a => a.ParentItemId) + .Select(a => a.ItemId) + .ToHashSet(); + } + private int ResolveAndDeleteItems(IReadOnlyCollection<Guid> ids, string description) { if (ids.Count == 0) 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/20260911120000_StripEmbeddedLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs new file mode 100644 index 0000000000..f61e42337a --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs @@ -0,0 +1,44 @@ +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Drops keys that no current property reads or writes from the serialized <c>BaseItems.Data</c> blob. +/// </summary> +[JellyfinMigration("2026-09-11T12:00:00", nameof(StripEmbeddedLinkedChildren))] +internal class StripEmbeddedLinkedChildren : IDatabaseMigrationRoutine +{ + private readonly ILogger<StripEmbeddedLinkedChildren> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + public StripEmbeddedLinkedChildren( + ILoggerFactory loggerFactory, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = loggerFactory.CreateLogger<StripEmbeddedLinkedChildren>(); + _dbProvider = dbProvider; + } + + /// <inheritdoc/> + public void Perform() + { + using var context = _dbProvider.CreateDbContext(); + + // json_valid guards the rare malformed blob: json_remove would abort the statement on it, + // and one bad row must not cost every other row the fix. + var updated = context.Database.ExecuteSqlRaw( + """ + UPDATE "BaseItems" + SET "Data" = json_remove("Data", '$.LinkedChildren', '$.ExtraIds', '$.SupportsExternalTransfer') + WHERE "Data" IS NOT NULL + AND json_valid("Data") = 1 + AND ("Data" LIKE '%"LinkedChildren"%' + OR "Data" LIKE '%"ExtraIds"%' + OR "Data" LIKE '%"SupportsExternalTransfer"%') + """); + + _logger.LogInformation("Dropped dead keys from the serialized data of {Count} items", updated); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs b/Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs new file mode 100644 index 0000000000..2824c3234f --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.Item; +using Jellyfin.Server.ServerSetupApp; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Collapses conflicting user data rows so every row an item holds for a user says the same thing. +/// </summary> +[JellyfinMigration("2026-09-12T12:00:00", nameof(HarmonizeConflictingUserData))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class HarmonizeConflictingUserData : IAsyncMigrationRoutine +{ + private const int BatchSize = 500; + + private readonly IStartupLogger<HarmonizeConflictingUserData> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="HarmonizeConflictingUserData"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + public HarmonizeConflictingUserData( + IStartupLogger<HarmonizeConflictingUserData> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var conflicts = await context.UserData + .Where(e => !e.ItemId.Equals(BaseItemRepository.PlaceholderId)) + .GroupBy(e => new { e.ItemId, e.UserId }) + .Where(g => g.Count() > 1 + && (g.Min(e => e.PlaybackPositionTicks) != g.Max(e => e.PlaybackPositionTicks) + || g.Min(e => e.PlayCount) != g.Max(e => e.PlayCount) + || g.Min(e => e.Played ? 1 : 0) != g.Max(e => e.Played ? 1 : 0) + || g.Min(e => e.IsFavorite ? 1 : 0) != g.Max(e => e.IsFavorite ? 1 : 0) + || g.Min(e => e.LastPlayedDate) != g.Max(e => e.LastPlayedDate))) + .Select(g => new { g.Key.ItemId, g.Key.UserId }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (conflicts.Count == 0) + { + _logger.LogInformation("No conflicting user data found."); + return; + } + + var conflictKeys = conflicts.ConvertAll(e => new ConflictKey(e.ItemId, e.UserId)); + + _logger.LogInformation("Harmonizing user data for {Count} item/user combinations.", conflictKeys.Count); + + var harmonized = 0; + foreach (var batch in conflictKeys.Chunk(BatchSize)) + { + harmonized += await HarmonizeBatchAsync(context, batch, cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation("Updated {Count} user data rows.", harmonized); + } + } + + private static async Task<int> HarmonizeBatchAsync(JellyfinDbContext context, ConflictKey[] batch, CancellationToken cancellationToken) + { + var itemIds = batch.Select(e => e.ItemId).Distinct().ToArray(); + var wanted = batch.ToHashSet(); + + var rows = await context.UserData + .WhereOneOrMany(itemIds, e => e.ItemId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var updated = 0; + foreach (var group in rows.GroupBy(e => new ConflictKey(e.ItemId, e.UserId))) + { + if (!wanted.Contains(group.Key)) + { + continue; + } + + // The most recent play is the state the user last produced; the others are fossils of + // earlier incarnations of the same item. + var winner = group + .OrderByDescending(e => e.LastPlayedDate) + .ThenByDescending(e => e.PlayCount) + .ThenByDescending(e => e.PlaybackPositionTicks) + .First(); + + foreach (var row in group) + { + if (ReferenceEquals(row, winner)) + { + continue; + } + + row.AudioStreamIndex = winner.AudioStreamIndex; + row.IsFavorite = winner.IsFavorite; + row.LastPlayedDate = winner.LastPlayedDate; + row.Likes = winner.Likes; + row.PlaybackPositionTicks = winner.PlaybackPositionTicks; + row.PlayCount = winner.PlayCount; + row.Played = winner.Played; + row.Rating = winner.Rating; + row.SubtitleStreamIndex = winner.SubtitleStreamIndex; + updated++; + } + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + context.ChangeTracker.Clear(); + + return updated; + } + + private readonly record struct ConflictKey(Guid ItemId, Guid UserId); +} diff --git a/Jellyfin.Server/Migrations/Routines/20260915104305_FixNullEncoderPreset.cs b/Jellyfin.Server/Migrations/Routines/20260915104305_FixNullEncoderPreset.cs new file mode 100644 index 0000000000..f8e63f1920 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260915104305_FixNullEncoderPreset.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using Jellyfin.Server.Migrations.Stages; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Replaces the nullable encoder preset written by older versions with the default preset. +/// </summary> +[JellyfinMigration("2026-09-15T10:43:05", nameof(FixNullEncoderPreset), Stage = JellyfinMigrationStageTypes.PreInitialisation)] +internal class FixNullEncoderPreset : IAsyncMigrationRoutine +{ + private static readonly XNamespace _xsiNamespace = "http://www.w3.org/2001/XMLSchema-instance"; + + private readonly IApplicationPaths _applicationPaths; + private readonly ILogger<FixNullEncoderPreset> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="FixNullEncoderPreset"/> class. + /// </summary> + /// <param name="applicationPaths">The application paths.</param> + /// <param name="logger">The logger.</param> + public FixNullEncoderPreset(IApplicationPaths applicationPaths, ILogger<FixNullEncoderPreset> logger) + { + _applicationPaths = applicationPaths; + _logger = logger; + } + + /// <inheritdoc /> + public Task PerformAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "encoding.xml"); + if (!File.Exists(path)) + { + return Task.CompletedTask; + } + + XDocument document; + try + { + document = XDocument.Load(path, LoadOptions.PreserveWhitespace); + } + catch (XmlException ex) + { + _logger.LogWarning(ex, "Could not parse encoding configuration; skipping null encoder preset migration"); + return Task.CompletedTask; + } + + var encoderPreset = document.Root?.Element(nameof(EncodingOptions.EncoderPreset)); + if (encoderPreset is null) + { + return Task.CompletedTask; + } + + var nilAttribute = encoderPreset.Attribute(_xsiNamespace + "nil"); + if (!string.Equals(nilAttribute?.Value, "true", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(encoderPreset.Value)) + { + return Task.CompletedTask; + } + + nilAttribute?.Remove(); + encoderPreset.Value = nameof(EncoderPreset.auto); + document.Save(path, SaveOptions.DisableFormatting); + _logger.LogInformation("Replaced null encoder preset with auto in encoding configuration"); + return Task.CompletedTask; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs index ed92c34aa3..903d91e046 100644 --- a/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/20260915120000_MigrateRatingLevels.cs @@ -11,7 +11,7 @@ namespace Jellyfin.Server.Migrations.Routines; /// Migrate rating levels. /// </summary> #pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2026-03-02T09:00:00", nameof(MigrateRatingLevels))] +[JellyfinMigration("2026-09-15T12:00:00", nameof(MigrateRatingLevels))] [JellyfinMigrationBackup(JellyfinDb = true)] #pragma warning restore CS0618 // Type or member is obsolete internal class MigrateRatingLevels : IDatabaseMigrationRoutine diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 35eaff6532..29c7d512d0 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -149,8 +149,16 @@ namespace Jellyfin.Server if (_restartOnShutdown) { _startTimestamp = Stopwatch.GetTimestamp(); - await _setupServer.StopAsync().ConfigureAwait(false); - await _setupServer.RunAsync().ConfigureAwait(false); + try + { + await _setupServer.StopAsync().ConfigureAwait(false); + await _setupServer.RunAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + // The startup page is optional, losing it must not take the restart down with it. + _logger.LogError(ex, "Failed to bring the startup server back up"); + } } } while (_restartOnShutdown); @@ -245,7 +253,15 @@ namespace Jellyfin.Server await appHost.RunStartupTasksAsync().ConfigureAwait(false); _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); - await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false); + try + { + await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + // A hosted service that throws or times out while stopping must not cancel a pending restart. + _logger.LogError(ex, "Failure while stopping the server"); + } } _restartOnShutdown = appHost.ShouldRestart; @@ -271,9 +287,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..022f8f0fbf 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); @@ -2531,8 +2560,9 @@ namespace MediaBrowser.Controller.Entities { var newDateModified = FileSystem.GetLastWriteTimeUtc(newImage); - // If date changed then we need to reset saved image dimensions - if (existing.DateModified != newDateModified && (existing.Width > 0 || existing.Height > 0)) + // If date changed then we need to reset saved image dimensions. Stores such as PostgreSQL keep + // only microseconds, so an exact comparison would treat every unchanged image as modified. + if (Math.Abs((existing.DateModified - newDateModified).TotalSeconds) > 1 && (existing.Width > 0 || existing.Height > 0)) { existing.Width = 0; existing.Height = 0; 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..e0f0e0d476 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -235,8 +235,6 @@ namespace MediaBrowser.Controller.Entities public string? Name { get; set; } - public bool? UseRawName { get; set; } - public string? Person { get; set; } public Guid[] PersonIds { get; set; } @@ -488,6 +486,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/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index 8d2a959f4d..50f749367f 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -65,5 +65,12 @@ namespace MediaBrowser.Controller.Entities /// people must satisfy through at least one of the items they are credited on. /// </summary> public InternalItemsQuery AccessFilter { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to keep only people that have a by-name item behind + /// them. Set by callers that hand back items rather than credits, so the count they report + /// describes the same people they can return. + /// </summary> + public bool MustHaveItem { 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..4983dbb874 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(); @@ -533,6 +546,8 @@ namespace MediaBrowser.Controller.Entities.TV { var info = GetItemLookupInfo<SeriesInfo>(); + info.DisplayOrder = DisplayOrder; + return info; } 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..ae15c75350 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -484,6 +484,15 @@ namespace MediaBrowser.Controller.Library string uniqueId); /// <summary> + /// Gets the identifier of a named user view. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="parentId">The parent identifier.</param> + /// <param name="viewType">Type of the view.</param> + /// <returns>The named view identifier.</returns> + Guid GetNamedViewId(User user, Guid parentId, CollectionType? viewType); + + /// <summary> /// Gets the shadow view. /// </summary> /// <param name="parent">The parent.</param> @@ -496,6 +505,15 @@ namespace MediaBrowser.Controller.Library string sortName); /// <summary> + /// Gets the identifier of a shadow view. + /// </summary> + /// <param name="name">The view name.</param> + /// <param name="parentId">The parent identifier.</param> + /// <param name="viewType">Type of the view.</param> + /// <returns>The shadow view identifier.</returns> + Guid GetShadowViewId(string name, Guid parentId, CollectionType? viewType); + + /// <summary> /// Gets the season number from path. /// </summary> /// <param name="path">The path.</param> @@ -833,6 +851,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..ebfa78176a 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -88,6 +88,7 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1); private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0); private readonly Version _minFFmpegNoiseBsfDrop = new Version(5, 0); + private readonly Version _minFFmpegAmfAv1EncCAQ = new Version(7, 1); private static readonly string[] _videoProfilesH264 = [ @@ -1668,14 +1669,6 @@ namespace MediaBrowser.Controller.MediaEncoding // TODO: probe QSV encoders' capabilities and enable more tuning options // See also https://github.com/intel/media-delivery/blob/master/doc/quality.rst - // Enable MacroBlock level bitrate control for better subjective visual quality - var mbbrcOpt = string.Empty; - if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) - { - mbbrcOpt = " -mbbrc 1"; - } - // Some less powerful H.264 HW decoders require strict CPB size // So bufsize optimizations should not be applied to them int factor = 2; @@ -1696,7 +1689,7 @@ namespace MediaBrowser.Controller.MediaEncoding int qsvInitOcc = (int)Math.Min((long)bitrate * 1 * factor, int.MaxValue); int qsvBufsize = (int)Math.Min((long)bitrate * 2 * factor, int.MaxValue); - return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {qsvMaxrate} -rc_init_occupancy {qsvInitOcc} -bufsize {qsvBufsize}"); + return string.Create(CultureInfo.InvariantCulture, $" -b:v {bitrate} -maxrate {qsvMaxrate} -rc_init_occupancy {qsvInitOcc} -bufsize {qsvBufsize}"); } if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase) @@ -1813,6 +1806,13 @@ namespace MediaBrowser.Controller.MediaEncoding EncoderPreset[] valid_presets = [EncoderPreset.veryslow, EncoderPreset.slower, EncoderPreset.slow, EncoderPreset.medium, EncoderPreset.fast, EncoderPreset.faster, EncoderPreset.veryfast]; param += " -preset " + (valid_presets.Contains(encoderPreset) ? encoderPreset : EncoderPreset.veryfast).ToString().ToLowerInvariant(); + + // Enable MacroBlock level bitrate control for better subjective visual quality + if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) + { + param += " -mbbrc 1"; + } } else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc) || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) // hevc (hevc_nvenc) @@ -1829,6 +1829,13 @@ namespace MediaBrowser.Controller.MediaEncoding EncoderPreset.faster => " -preset p2", _ => " -preset p1" }; + + // Enable Spatial Adaptive Quantization for better subjective visual quality + // H.264 is skipped for now because Maxwell 1 does not support it + if (!string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)) + { + param += " -spatial-aq 1 -aq-strength 15"; + } } else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf) || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) // hevc (hevc_amf) @@ -1844,6 +1851,18 @@ namespace MediaBrowser.Controller.MediaEncoding _ => " -quality speed" }; + // Enable Variance Based/Context Adaptive Quantization for better subjective visual quality + if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + param += " -vbaq 1"; + } + else if (string.Equals(videoEncoder, "av1_amf", StringComparison.OrdinalIgnoreCase) + && _mediaEncoder.EncoderVersion >= _minFFmpegAmfAv1EncCAQ) + { + param += " -aq_mode caq"; + } + if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) || string.Equals(videoEncoder, "av1_amf", StringComparison.OrdinalIgnoreCase)) { @@ -2693,6 +2712,28 @@ namespace MediaBrowser.Controller.MediaEncoding return reasons; } + private static int GetBitDepthCompensatedBitrate(int bitrate, MediaStream videoStream, int targetBitDepth) + { + if (targetBitDepth >= 10 + || (videoStream.BitDepth is int bitDepth && bitDepth <= targetBitDepth) + || videoStream.BitRate is not int sourceBitrate) + { + return bitrate; + } + + // Bit depth reduction requires bit rate compensation because + // 10-bit encoded video is significantly more efficient. + var factor = sourceBitrate switch + { + <= 4_000_000 => 1.5, + <= 8_000_000 => 1.2, + <= 12_000_000 => 1.1, + _ => 1.05 + }; + + return Convert.ToInt32(bitrate * factor); + } + public int GetVideoBitrateParamValue(BaseEncodingJobOptions request, MediaStream videoStream, string outputVideoCodec) { var bitrate = request.VideoBitRate; @@ -2715,6 +2756,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (bitrate.HasValue) { var inputVideoCodec = videoStream.Codec; + bitrate = GetBitDepthCompensatedBitrate(bitrate.Value, videoStream, 8); bitrate = ScaleBitrate(bitrate.Value, inputVideoCodec, outputVideoCodec); // If a max bitrate was requested, don't let the scaled bitrate exceed it @@ -3807,6 +3849,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 +3877,10 @@ namespace MediaBrowser.Controller.MediaEncoding tonemapArg += ":range=" + range.ToString().ToLowerInvariant(); } } + else if (forceFullRange) + { + formatArg += ":range=pc"; + } return string.Format( CultureInfo.InvariantCulture, @@ -5470,7 +5521,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 +5598,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) @@ -7809,7 +7874,9 @@ namespace MediaBrowser.Controller.MediaEncoding var channels = state.OutputAudioChannels; - var useDownMixAlgorithm = state.AudioStream is not null + // Must match the condition under which GetAudioFilterParam emits the downmix filter. + var useDownMixAlgorithm = channels == 2 + && state.AudioStream?.Channels > 2 && DownMixAlgorithmsHelper.AlgorithmFilterStrings.ContainsKey((encodingOptions.DownMixStereoAlgorithm, DownMixAlgorithmsHelper.InferChannelLayout(state.AudioStream))); if (channels.HasValue && !useDownMixAlgorithm) @@ -7904,6 +7971,13 @@ namespace MediaBrowser.Controller.MediaEncoding audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture)); } + // Without the downmix filter, -ac 2 alone drops the LFE channel. + var audioFilterParam = GetAudioFilterParam(state, encodingOptions); + if (!string.IsNullOrEmpty(audioFilterParam)) + { + audioTranscodeParams.Add(audioFilterParam.TrimStart()); + } + // Copy the movflags from GetProgressiveVideoFullCommandLine // See #9248 and the associated PR for why this is needed if (_mp4ContainerNames.Contains(state.OutputContainer)) diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs index 5045030b9b..12600af384 100644 --- a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Diagnostics; using System.Threading; using MediaBrowser.Model.Dto; @@ -14,6 +15,7 @@ public sealed class TranscodingJob : IDisposable private readonly ILogger<TranscodingJob> _logger; private readonly Lock _processLock = new(); private readonly Lock _timerLock = new(); + private readonly ConcurrentDictionary<int, long> _segmentEndPositionTicks = new(); private int _activeRequestCount; private Timer? _killTimer; @@ -157,6 +159,40 @@ public sealed class TranscodingJob : IDisposable public int PingTimeout { get; set; } /// <summary> + /// Records the end position of a served segment. + /// </summary> + /// <param name="segmentIndex">The segment index.</param> + /// <param name="segmentEndPositionTicks">The segment end position ticks.</param> + public void ReportSegmentDownloaded(int segmentIndex, long segmentEndPositionTicks) + { + if (segmentIndex < 0) + { + return; + } + + _segmentEndPositionTicks[segmentIndex] = segmentEndPositionTicks; + } + + /// <summary> + /// Gets the highest index among served segments whose end position is at or before the given position. + /// </summary> + /// <param name="positionTicks">The position ticks.</param> + /// <returns>The segment index, or <c>null</c> if there is none.</returns> + public int? GetHighestServedSegmentIndexEndingAtOrBefore(long positionTicks) + { + int? result = null; + foreach (var (index, endPositionTicks) in _segmentEndPositionTicks) + { + if (endPositionTicks <= positionTicks && (result is null || index > result)) + { + result = index; + } + } + + return result; + } + + /// <summary> /// Increments the active request count. /// </summary> /// <returns>The incremented count.</returns> diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs index 67bfcb02fd..924fc2e659 100644 --- a/MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingSegmentCleaner.cs @@ -22,7 +22,6 @@ public class TranscodingSegmentCleaner : IDisposable private readonly IFileSystem _fileSystem; private readonly IMediaEncoder _mediaEncoder; private Timer? _timer; - private int _segmentLength; /// <summary> /// Initializes a new instance of the <see cref="TranscodingSegmentCleaner"/> class. @@ -32,7 +31,7 @@ public class TranscodingSegmentCleaner : IDisposable /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="segmentLength">The segment length of this transcoding job.</param> + /// <param name="segmentLength">The segment length of this transcoding job. Unused.</param> public TranscodingSegmentCleaner(TranscodingJob job, ILogger<TranscodingSegmentCleaner> logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder, int segmentLength) { _job = job; @@ -40,7 +39,6 @@ public class TranscodingSegmentCleaner : IDisposable _config = config; _fileSystem = fileSystem; _mediaEncoder = mediaEncoder; - _segmentLength = segmentLength; } /// <summary> @@ -104,11 +102,11 @@ public class TranscodingSegmentCleaner : IDisposable if (downloadPositionSeconds > 0 && segmentKeepSeconds > 0 && downloadPositionSeconds > segmentKeepSeconds) { - var idxMaxToDelete = (downloadPositionSeconds - segmentKeepSeconds) / _segmentLength; + var idxMaxToDelete = _job.GetHighestServedSegmentIndexEndingAtOrBefore(downloadPositionTicks - TimeSpan.FromSeconds(segmentKeepSeconds).Ticks); if (idxMaxToDelete > 0) { - await DeleteSegmentFiles(_job, 0, idxMaxToDelete, 1500).ConfigureAwait(false); + await DeleteSegmentFiles(_job, 0, idxMaxToDelete.Value, 1500).ConfigureAwait(false); } } } 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/Providers/SeriesInfo.cs b/MediaBrowser.Controller/Providers/SeriesInfo.cs index 5ca5f0a534..84a6d71d4f 100644 --- a/MediaBrowser.Controller/Providers/SeriesInfo.cs +++ b/MediaBrowser.Controller/Providers/SeriesInfo.cs @@ -5,5 +5,9 @@ namespace MediaBrowser.Controller.Providers /// </summary> public class SeriesInfo : ItemLookupInfo { + /// <summary> + /// Gets or sets the canned display order group. + /// </summary> + public string? DisplayOrder { get; set; } } } diff --git a/MediaBrowser.Controller/Security/IAuthenticationManager.cs b/MediaBrowser.Controller/Security/IAuthenticationManager.cs index 79b2a2444e..aebb229e79 100644 --- a/MediaBrowser.Controller/Security/IAuthenticationManager.cs +++ b/MediaBrowser.Controller/Security/IAuthenticationManager.cs @@ -12,8 +12,8 @@ namespace MediaBrowser.Controller.Security /// Creates an API key. /// </summary> /// <param name="name">The name of the key.</param> - /// <returns>A task representing the creation of the key.</returns> - Task CreateApiKey(string name); + /// <returns>A task representing the creation of the key, containing the created key.</returns> + Task<AuthenticationInfo> CreateApiKey(string name); /// <summary> /// Gets the API keys. 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.MediaEncoding/Probing/MediaStreamInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs index f631c471f6..17ab378ef7 100644 --- a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs +++ b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs @@ -198,10 +198,10 @@ namespace MediaBrowser.MediaEncoding.Probing public string? CodecTag { get; set; } /// <summary> - /// Gets or sets the codec_tag_string?. + /// Gets or sets the codec_tag_string. /// </summary> - /// <value>The codec_tag_string?.</value> - [JsonPropertyName("codec_tag_string?")] + /// <value>The codec_tag_string.</value> + [JsonPropertyName("codec_tag_string")] public string? CodecTagString { get; set; } /// <summary> diff --git a/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs index fcb450ed30..8aa234a1c3 100644 --- a/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs +++ b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs @@ -1,7 +1,7 @@ #nullable disable #pragma warning disable CS1591 -using System; +using System.Collections.Generic; namespace MediaBrowser.Model.Querying { @@ -9,18 +9,18 @@ namespace MediaBrowser.Model.Querying { public QueryFiltersLegacy() { - Genres = Array.Empty<string>(); - Tags = Array.Empty<string>(); - OfficialRatings = Array.Empty<string>(); - Years = Array.Empty<int>(); + Genres = []; + Tags = []; + OfficialRatings = []; + Years = []; } - public string[] Genres { get; set; } + public IReadOnlyList<string> Genres { get; set; } - public string[] Tags { get; set; } + public IReadOnlyList<string> Tags { get; set; } - public string[] OfficialRatings { get; set; } + public IReadOnlyList<string> OfficialRatings { get; set; } - public int[] Years { get; set; } + public IReadOnlyList<int> Years { get; set; } } } 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/Books/OpenPackagingFormat/EpubImageProvider.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs index 69cae77628..dbd8b28198 100644 --- a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs @@ -64,7 +64,8 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat } var cover = coverReference.Value; - var coverFile = epub.GetEntry(cover.Path); + // ZIP entry names always use '/', but the cover path is built with Path.Combine, which uses '\' on Windows + var coverFile = epub.GetEntry(cover.Path.Replace('\\', '/')); if (coverFile == null) { diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs index e5d2987312..d60b50701e 100644 --- a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs @@ -1,4 +1,3 @@ -using System.IO; using System.IO.Compression; using System.Linq; using System.Xml.Linq; @@ -17,7 +16,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat /// <returns>The content file path.</returns> public static string? ReadContentFilePath(ZipArchive epub) { - var container = epub.GetEntry(Path.Combine("META-INF", "container.xml")); + var container = epub.GetEntry("META-INF/container.xml"); if (container == null) { return null; diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 727f481b65..3317e33e1c 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); } @@ -461,8 +463,9 @@ namespace MediaBrowser.Providers.Manager { var newDateModified = _fileSystem.GetLastWriteTimeUtc(image.FileInfo); - // If date changed then we need to reset saved image dimensions - if (currentImage.DateModified != newDateModified && (currentImage.Width > 0 || currentImage.Height > 0)) + // If date changed then we need to reset saved image dimensions. Stores such as PostgreSQL keep + // only microseconds, so an exact comparison would treat every unchanged image as modified. + if (Math.Abs((currentImage.DateModified - newDateModified).TotalSeconds) > 1 && (currentImage.Width > 0 || currentImage.Height > 0)) { currentImage.Width = 0; currentImage.Height = 0; 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 616fc94603..855765441b 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/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 7dee5fd31d..d8e6acae17 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -70,7 +70,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (!string.IsNullOrWhiteSpace(artistMusicBrainzId)) { - var releaseSearchResults = await query.FindReleasesAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken) + var releaseSearchResults = await query.FindReleasesWithRetryAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", _logger, cancellationToken) .ConfigureAwait(false); if (releaseSearchResults.Results.Count > 0) @@ -83,7 +83,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu // I'm sure there is a better way but for now it resolves search for 12" Mixes var queryName = searchInfo.Name.Replace("\"", string.Empty, StringComparison.Ordinal); - var releaseSearchResults = await query.FindReleasesAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", null, null, false, cancellationToken) + var releaseSearchResults = await query.FindReleasesWithRetryAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", _logger, cancellationToken) .ConfigureAwait(false); if (releaseSearchResults.Results.Count > 0) @@ -200,13 +200,13 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (!string.IsNullOrEmpty(artistMusicBrainzId)) { - var releaseSearchResults = await query.FindReleasesAsync($"\"{info.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken) + var releaseSearchResults = await query.FindReleasesWithRetryAsync($"\"{info.Name}\" AND arid:{artistMusicBrainzId}", _logger, cancellationToken) .ConfigureAwait(false); releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null; } else if (!string.IsNullOrEmpty(info.GetAlbumArtist())) { - var releaseSearchResults = await query.FindReleasesAsync($"\"{info.Name}\" AND artist:{info.GetAlbumArtist()}", null, null, false, cancellationToken) + var releaseSearchResults = await query.FindReleasesWithRetryAsync($"\"{info.Name}\" AND artist:{info.GetAlbumArtist()}", _logger, cancellationToken) .ConfigureAwait(false); releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null; } @@ -290,7 +290,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (artistCredit is not null && artistCredit.Count > 0) { item.AlbumArtists = artistCredit - .Select(credit => credit.Name) + .Select(credit => credit.Artist?.Name ?? credit.Name) .Where(name => !string.IsNullOrWhiteSpace(name)) .ToArray(); } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index c3d13ed42c..eb189f1258 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -60,7 +60,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar return []; } - var artistSearchResults = await query.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken) + var artistSearchResults = await query.FindArtistsWithRetryAsync($"\"{searchInfo.Name}\"", _logger, cancellationToken) .ConfigureAwait(false); if (artistSearchResults.Results.Count > 0) { @@ -70,7 +70,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar if (searchInfo.Name.HasDiacritics()) { // Try again using the search with an accented characters query - var artistAccentsSearchResults = await query.FindArtistsAsync($"artistaccent:\"{searchInfo.Name}\"", null, null, false, cancellationToken) + var artistAccentsSearchResults = await query.FindArtistsWithRetryAsync($"artistaccent:\"{searchInfo.Name}\"", _logger, cancellationToken) .ConfigureAwait(false); if (artistAccentsSearchResults.Results.Count > 0) { diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs index f3df41e942..620a0b3c45 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using MetaBrainz.Common; using MetaBrainz.MusicBrainz; using MetaBrainz.MusicBrainz.Interfaces.Entities; +using MetaBrainz.MusicBrainz.Interfaces.Searches; using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Plugins.MusicBrainz; @@ -15,6 +16,15 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz; internal static class MusicBrainzQueryExtensions { /// <summary> + /// The number of extra attempts made when MusicBrainz reports a transient failure. + /// </summary> + private const int MaxRetries = 2; + + private static readonly TimeSpan _minimumRetryDelay = TimeSpan.FromSeconds(1); + + private static readonly TimeSpan _maximumRetryDelay = TimeSpan.FromSeconds(15); + + /// <summary> /// Parses a MusicBrainz identifier, which may come from user-supplied tags or NFO files and is therefore not /// guaranteed to be a valid GUID. /// </summary> @@ -49,10 +59,11 @@ internal static class MusicBrainzQueryExtensions /// <returns>The release, or <see langword="null"/> if MusicBrainz does not have it.</returns> public static Task<IRelease?> LookupReleaseOrNullAsync(this Query query, Guid releaseId, Include include, ILogger logger, CancellationToken cancellationToken) => NotFoundAsNullAsync( - () => query.LookupReleaseAsync(releaseId, include, cancellationToken), + token => query.LookupReleaseAsync(releaseId, include, token), "release", releaseId, - logger); + logger, + cancellationToken); /// <summary> /// Looks up a release group, treating an unknown identifier as missing data rather than an error. @@ -65,10 +76,11 @@ internal static class MusicBrainzQueryExtensions /// <returns>The release group, or <see langword="null"/> if MusicBrainz does not have it.</returns> public static Task<IReleaseGroup?> LookupReleaseGroupOrNullAsync(this Query query, Guid releaseGroupId, Include include, ILogger logger, CancellationToken cancellationToken) => NotFoundAsNullAsync( - () => query.LookupReleaseGroupAsync(releaseGroupId, include, null, cancellationToken), + token => query.LookupReleaseGroupAsync(releaseGroupId, include, null, token), "release group", releaseGroupId, - logger); + logger, + cancellationToken); /// <summary> /// Looks up an artist, treating an unknown identifier as missing data rather than an error. @@ -81,10 +93,141 @@ internal static class MusicBrainzQueryExtensions /// <returns>The artist, or <see langword="null"/> if MusicBrainz does not have it.</returns> public static Task<IArtist?> LookupArtistOrNullAsync(this Query query, Guid artistId, Include include, ILogger logger, CancellationToken cancellationToken) => NotFoundAsNullAsync( - () => query.LookupArtistAsync(artistId, include, null, null, cancellationToken), + token => query.LookupArtistAsync(artistId, include, null, null, token), "artist", artistId, - logger); + logger, + cancellationToken); + + /// <summary> + /// Searches for artists, retrying when the MusicBrainz server is too busy to answer. + /// </summary> + /// <param name="query">The MusicBrainz query client.</param> + /// <param name="searchQuery">The search query.</param> + /// <param name="logger">The logger.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The search results.</returns> + public static Task<ISearchResults<ISearchResult<IArtist>>> FindArtistsWithRetryAsync(this Query query, string searchQuery, ILogger logger, CancellationToken cancellationToken) + => RetryOnTransientErrorAsync( + token => query.FindArtistsAsync(searchQuery, null, null, false, token), + "artist search", + logger, + cancellationToken); + + /// <summary> + /// Searches for releases, retrying when the MusicBrainz server is too busy to answer. + /// </summary> + /// <param name="query">The MusicBrainz query client.</param> + /// <param name="searchQuery">The search query.</param> + /// <param name="logger">The logger.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The search results.</returns> + public static Task<ISearchResults<ISearchResult<IRelease>>> FindReleasesWithRetryAsync(this Query query, string searchQuery, ILogger logger, CancellationToken cancellationToken) + => RetryOnTransientErrorAsync( + token => query.FindReleasesAsync(searchQuery, null, null, false, token), + "release search", + logger, + cancellationToken); + + /// <summary> + /// Runs a request, retrying it when MusicBrainz reports a transient failure. MusicBrainz sheds load with + /// HTTP 503 when its servers are busy, which is not specific to this client and succeeds when retried, so + /// failing the whole refresh on the first one would leave items without metadata for no good reason. + /// </summary> + /// <typeparam name="T">The type of the request result.</typeparam> + /// <param name="request">The request to run.</param> + /// <param name="operation">The operation being performed, used for logging.</param> + /// <param name="logger">The logger.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The request result.</returns> + internal static async Task<T> RetryOnTransientErrorAsync<T>(Func<CancellationToken, Task<T>> request, string operation, ILogger logger, CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + return await request(cancellationToken).ConfigureAwait(false); + } + catch (HttpError ex) when (attempt <= MaxRetries && IsTransient(ex.Status)) + { + var delay = GetRetryDelay(ex, attempt); + logger.LogDebug( + ex, + "MusicBrainz {Operation} failed with {Status}, retrying in {Delay} (attempt {Attempt} of {Attempts})", + operation, + ex.Status, + delay, + attempt, + MaxRetries + 1); + + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + } + } + + /// <summary> + /// Determines whether a response status is worth retrying. These are all cases of the server being unable to + /// answer right now rather than of the request itself being wrong. + /// </summary> + /// <param name="status">The status returned by MusicBrainz.</param> + /// <returns>Whether the request should be retried.</returns> + private static bool IsTransient(HttpStatusCode status) + => status is HttpStatusCode.TooManyRequests + or HttpStatusCode.BadGateway + or HttpStatusCode.ServiceUnavailable + or HttpStatusCode.GatewayTimeout; + + /// <summary> + /// Works out how long to wait before retrying. MusicBrainz reports when its current rate limit window ends and + /// retrying before then is documented to fail, so that hint wins over the exponential backoff when it is longer. + /// </summary> + /// <param name="error">The error returned by MusicBrainz.</param> + /// <param name="attempt">The number of the attempt that just failed.</param> + /// <returns>The time to wait before the next attempt.</returns> + internal static TimeSpan GetRetryDelay(HttpError error, int attempt) + { + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)); + var hint = GetServerHint(error); + if (hint > delay) + { + delay = hint.Value; + } + + if (delay < _minimumRetryDelay) + { + return _minimumRetryDelay; + } + + return delay > _maximumRetryDelay ? _maximumRetryDelay : delay; + } + + private static TimeSpan? GetServerHint(HttpError error) + { + var headers = error.ResponseHeaders; + if (headers is null) + { + return null; + } + + var retryAfter = headers.RetryAfter; + if (retryAfter?.Delta is { } delta) + { + return delta; + } + + if (retryAfter?.Date is { } date) + { + return date - DateTimeOffset.UtcNow; + } + + var rateLimit = new RateLimitInfo(headers); + if (rateLimit.ResetIn is { } resetIn) + { + return TimeSpan.FromSeconds(resetIn); + } + + return rateLimit.ResetAt - rateLimit.LastRequest; + } /// <summary> /// Runs a lookup, mapping a "not found" response to <see langword="null"/>. Identifiers stored on a library item @@ -95,13 +238,14 @@ internal static class MusicBrainzQueryExtensions /// <param name="entityType">The type of entity being looked up, used for logging.</param> /// <param name="id">The identifier being looked up, used for logging.</param> /// <param name="logger">The logger.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The entity, or <see langword="null"/> if MusicBrainz does not have it.</returns> - private static async Task<T?> NotFoundAsNullAsync<T>(Func<Task<T>> lookup, string entityType, Guid id, ILogger logger) + private static async Task<T?> NotFoundAsNullAsync<T>(Func<CancellationToken, Task<T>> lookup, string entityType, Guid id, ILogger logger, CancellationToken cancellationToken) where T : class { try { - return await lookup().ConfigureAwait(false); + return await RetryOnTransientErrorAsync(lookup, entityType + " lookup", logger, cancellationToken).ConfigureAwait(false); } catch (HttpError ex) when (ex.Status == HttpStatusCode.NotFound) { diff --git a/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs index 7e9befa8c6..0278469807 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs @@ -1,4 +1,5 @@ using System; +using System.Net; using System.Text.Json; using System.Text.Json.Serialization; using Jellyfin.Extensions.Json; @@ -6,7 +7,7 @@ using Jellyfin.Extensions.Json; namespace MediaBrowser.Providers.Plugins.Omdb { /// <summary> - /// Converts a string <c>N/A</c> to <c>string.Empty</c>. + /// Converts a string <c>N/A</c> to <c>string.Empty</c> and decodes HTML entities in every other string. /// </summary> public class JsonOmdbNotAvailableStringConverter : JsonConverter<string?> { @@ -27,7 +28,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb return null; } - return str; + // Some OMDb records are HTML encoded, e.g. the cast of tt0093058 lists "Vincent D'Onofrio". + // Stored verbatim that name is a second person next to the correctly spelled one. + return WebUtility.HtmlDecode(str); } return JsonSerializer.Deserialize<string?>(ref reader, options); diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index 7b245ea5a7..c81ada3e00 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -13,7 +13,6 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -45,9 +44,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb _libraryManager = libraryManager; _omdbProvider = new OmdbProvider(_httpClientFactory, fileSystem, configurationManager); - _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options); - _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStringConverter()); - _jsonOptions.Converters.Add(new JsonOmdbNotAvailableInt32Converter()); + _jsonOptions = OmdbProvider.CreateJsonOptions(); } public string Name => "The Open Movie Database"; diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index d51d913caa..60e4001189 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -45,10 +45,19 @@ namespace MediaBrowser.Providers.Plugins.Omdb _fileSystem = fileSystem; _configurationManager = configurationManager; - _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options); - // These converters need to take priority - _jsonOptions.Converters.Insert(0, new JsonOmdbNotAvailableStringConverter()); - _jsonOptions.Converters.Insert(0, new JsonOmdbNotAvailableInt32Converter()); + _jsonOptions = CreateJsonOptions(); + } + + /// <summary>Creates the <see cref="JsonSerializerOptions"/> to read OMDB responses with.</summary> + /// <returns>The options.</returns> + internal static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonDefaults.Options); + // These have to be inserted, appending leaves the default string converter ahead of them + options.Converters.Insert(0, new JsonOmdbNotAvailableStringConverter()); + options.Converters.Insert(0, new JsonOmdbNotAvailableInt32Converter()); + + return options; } /// <summary>Fetches data from OMDB service.</summary> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeGroupId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeGroupId.cs new file mode 100644 index 0000000000..7ce92949fe --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeGroupId.cs @@ -0,0 +1,30 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV +{ + /// <summary> + /// TMDb series or season group id. + /// </summary> + public class TmdbEpisodeGroupId : IExternalId + { + internal const string ProviderKey = "TmdbEpisodeGroup"; + + /// <inheritdoc /> + public string ProviderName => TmdbUtils.ProviderName; + + /// <inheritdoc /> + public string Key => ProviderKey; + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) + { + return item is Series or Season; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs index 1f8c87397d..6d3b9a4283 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs @@ -69,11 +69,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return Enumerable.Empty<RemoteImageInfo>(); } + var episodeGroup = series.GetProviderId(TmdbEpisodeGroupId.ProviderKey); var language = item.GetPreferredMetadataLanguage(); // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var episodeResult = await _tmdbClientManager - .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, series.DisplayOrder, null, null, null, cancellationToken) + .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, episodeGroup, null, null, null, cancellationToken) .ConfigureAwait(false); var stills = episodeResult?.Images?.Stills; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 8172ab14df..778a28b292 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -104,6 +104,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return metadataResult; } + var episodeGroup = info.SeriesProviderIds.GetValueOrDefault(TmdbEpisodeGroupId.ProviderKey); TvEpisode? episodeResult = null; if (info.IndexNumberEnd.HasValue) { @@ -112,7 +113,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV List<TvEpisode>? result = null; for (int? episode = startindex; episode <= endindex; episode++) { - var episodeInfo = await _tmdbClientManager.GetEpisodeAsync(seriesTmdbId, seasonNumber, episode.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + var episodeInfo = await _tmdbClientManager.GetEpisodeAsync(seriesTmdbId, seasonNumber, episode.Value, episodeGroup, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (episodeInfo is not null) { (result ??= new List<TvEpisode>()).Add(episodeInfo); @@ -124,6 +125,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Forces a deep copy of the first TvEpisode, so we don't modify the original because it's cached episodeResult = new TvEpisode() { + Id = result[0].Id, Name = result[0].Name, Overview = result[0].Overview, AirDate = result[0].AirDate, @@ -156,7 +158,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV else { episodeResult = await _tmdbClientManager - .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) + .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, episodeGroup, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); } @@ -188,6 +190,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV CommunityRating = Convert.ToSingle(episodeResult.VoteAverage) }; + if (episodeResult.Id is int episodeTmdbId and > 0) + { + item.SetProviderId(MetadataProvider.Tmdb, episodeTmdbId.ToString(CultureInfo.InvariantCulture)); + } + var externalIds = episodeResult.ExternalIds; item.TrySetProviderId(MetadataProvider.Tvdb, externalIds?.TvdbId); item.TrySetProviderId(MetadataProvider.Imdb, externalIds?.ImdbId); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs index b44361e4e7..7646c08329 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs @@ -118,7 +118,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // create a duplicate. // When missing episodes are disabled, this pass also prunes virtual episodes that aired more // than the grace period ago, as well as any specials when specials are not wanted. - var (existingEpisodes, updatableEpisodes) = GetExistingEpisodes(item, !importMissing, today, gracePeriodDays, importSpecials, out var prunedEpisodes); + var (existingEpisodes, updatableEpisodes, physicalTmdbIds) = GetExistingEpisodes(item, !importMissing, today, gracePeriodDays, importSpecials, out var prunedEpisodes); var seasonsByNumber = item.GetRecursiveChildren(i => i is Season) .OfType<Season>() @@ -197,6 +197,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV continue; } + // The slot is free, but the episode itself may not be: when the files follow a different + // order, the same episode sits at another number and creating this one would duplicate it. + if (physicalTmdbIds.Contains(tmdbEpisode.Id.ToString(CultureInfo.InvariantCulture))) + { + continue; + } + var targetSeason = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false); var newEpisode = AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate); await EnsureEpisodeImageAsync(newEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false); @@ -364,11 +371,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)); } - private (HashSet<(int Season, int Episode)> Keys, Dictionary<(int Season, int Episode), Episode> Updatable) GetExistingEpisodes(Series series, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials, out bool pruned) + private (HashSet<(int Season, int Episode)> Keys, Dictionary<(int Season, int Episode), Episode> Updatable, HashSet<string> PhysicalTmdbIds) GetExistingEpisodes(Series series, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials, out bool pruned) { var keys = new HashSet<(int Season, int Episode)>(); var updatable = new Dictionary<(int Season, int Episode), Episode>(); var physicalKeys = new HashSet<(int Season, int Episode)>(); + var physicalTmdbIds = new HashSet<string>(StringComparer.Ordinal); var ourVirtuals = new List<((int Season, int Episode) Key, Episode Episode)>(); pruned = false; @@ -403,6 +411,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV continue; } + if (!episode.IsVirtualItem) + { + // Which episode a file holds is what TMDb's episode id says, not the number the file is + // stored under: aired, DVD and absolute orders number the same episodes differently. + var physicalTmdbId = episode.GetProviderId(MetadataProvider.Tmdb); + if (!string.IsNullOrEmpty(physicalTmdbId)) + { + physicalTmdbIds.Add(physicalTmdbId); + } + } + if (episode.ParentIndexNumber.HasValue && episode.IndexNumber.HasValue) { var key = (episode.ParentIndexNumber.Value, episode.IndexNumber.Value); @@ -432,6 +451,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV DeleteEpisode(episode, "a physical episode now exists for this slot"); pruned = true; } + else if (physicalTmdbIds.Contains(episode.GetProviderId(MetadataProvider.Tmdb)!)) + { + DeleteEpisode(episode, "the same episode is already present under another number"); + pruned = true; + } else { // Virtual episodes this provider created are candidates for metadata sync. @@ -439,7 +463,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } } - return (keys, updatable); + return (keys, updatable, physicalTmdbIds); } /// <summary> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 9b8803f171..0eb8bb1513 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -12,6 +12,7 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; +using TMDbLib.Objects.TvShows; namespace MediaBrowser.Providers.Plugins.Tmdb.TV { @@ -64,6 +65,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return result; } + TvGroup? groupResult = null; + if (info.SeriesProviderIds.TryGetValue(TmdbEpisodeGroupId.ProviderKey, out var episodeGroup) && !string.IsNullOrWhiteSpace(episodeGroup)) + { + groupResult = await _tmdbClientManager.GetSeriesGroupAsync(episodeGroup, seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + } + result.HasMetadata = true; result.Item = new Season { @@ -81,6 +88,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV result.Item.TrySetProviderId(MetadataProvider.Tmdb, seasonResult.Id?.ToString(CultureInfo.InvariantCulture)); result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds?.TvdbId); + if (groupResult is not null && !string.IsNullOrWhiteSpace(groupResult.Id)) + { + result.Item.TrySetProviderId(TmdbEpisodeGroupId.ProviderKey, groupResult.Id); + } + var credits = seasonResult.Credits; if (credits?.Cast is not null) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index b6bb7aa013..155548ec1e 100755 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -62,7 +62,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (series is not null) { - var remoteResult = MapTvShowToRemoteSearchResult(series); + var remoteResult = MapTvShowToRemoteSearchResult(series, searchInfo); return new[] { remoteResult }; } @@ -126,7 +126,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return remoteResults; } - private RemoteSearchResult MapTvShowToRemoteSearchResult(TvShow series) + private RemoteSearchResult MapTvShowToRemoteSearchResult(TvShow series, SeriesInfo search) { var remoteResult = new RemoteSearchResult { @@ -144,6 +144,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV remoteResult.TrySetProviderId(MetadataProvider.Tvdb, series.ExternalIds.TvdbId); } + WithDisplayOrder(series, remoteResult, search); + remoteResult.PremiereDate = series.FirstAirDate?.ToUniversalTime(); remoteResult.ProductionYear = series.FirstAirDate?.Year; @@ -231,7 +233,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV result = new MetadataResult<Series> { - Item = MapTvShowToSeries(tvShow, info.MetadataCountryCode), + Item = MapTvShowToSeries(tvShow, info.MetadataCountryCode, info), ResultLanguage = info.MetadataLanguage ?? tvShow.OriginalLanguage }; @@ -245,7 +247,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return result; } - private static Series MapTvShowToSeries(TvShow seriesResult, string preferredCountryCode) + private static Series MapTvShowToSeries(TvShow seriesResult, string preferredCountryCode, SeriesInfo search) { var series = new Series { @@ -359,6 +361,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV series.OriginalLanguage = seriesResult.OriginalLanguage; } + WithDisplayOrder(seriesResult, series, search); + return series; } @@ -451,5 +455,39 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); } + + private static TvGroupType? MapDisplayOrderToTvGroupType(string? displayOrder) + { + return displayOrder switch + { + "originalAirDate" => TvGroupType.OriginalAirDate, + "absolute" => TvGroupType.Absolute, + "dvd" => TvGroupType.DVD, + "digital" => TvGroupType.Digital, + "storyArc" => TvGroupType.StoryArc, + "production" => TvGroupType.Production, + "tv" => TvGroupType.TV, + _ => null, + }; + } + + private static void WithDisplayOrder(TvShow searchResult, IHasProviderIds providerIds, SeriesInfo search) + { + if (!search.TryGetProviderId(TmdbEpisodeGroupId.ProviderKey, out var episodeGroupId) + && searchResult.EpisodeGroups?.Results is not null) + { + var tvGroupType = MapDisplayOrderToTvGroupType(search.DisplayOrder); + if (tvGroupType is not null) + { + var episodeGroup = searchResult.EpisodeGroups.Results.Find(g => g.Type == tvGroupType); + if (episodeGroup is not null) + { + episodeGroupId = episodeGroup.Id; + } + } + } + + providerIds.TrySetProviderId(TmdbEpisodeGroupId.ProviderKey, episodeGroupId); + } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index fb3e5ee92b..7d119e8b84 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; @@ -162,53 +170,31 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> - /// Gets a tv show episode group from the TMDb API based on the show id and the display order. + /// Gets a tv show episode group from the TMDb API based on the episode group. /// </summary> - /// <param name="tvShowId">The tv show's TMDb id.</param> - /// <param name="displayOrder">The display order.</param> + /// <param name="episodeGroupId">The Tmdb episode group.</param> + /// <param name="seasonNumber">The season number of the episode group.</param> /// <param name="language">The tv show's language.</param> /// <param name="imageLanguages">A comma-separated list of image languages.</param> /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv show episode group information or null if not found.</returns> - private async Task<TvGroupCollection?> GetSeriesGroupAsync(int tvShowId, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) + public async Task<TvGroup?> GetSeriesGroupAsync(string episodeGroupId, int seasonNumber, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { - TvGroupType? groupType = - string.Equals(displayOrder, "originalAirDate", StringComparison.Ordinal) ? TvGroupType.OriginalAirDate : - string.Equals(displayOrder, "absolute", StringComparison.Ordinal) ? TvGroupType.Absolute : - string.Equals(displayOrder, "dvd", StringComparison.Ordinal) ? TvGroupType.DVD : - string.Equals(displayOrder, "digital", StringComparison.Ordinal) ? TvGroupType.Digital : - string.Equals(displayOrder, "storyArc", StringComparison.Ordinal) ? TvGroupType.StoryArc : - string.Equals(displayOrder, "production", StringComparison.Ordinal) ? TvGroupType.Production : - string.Equals(displayOrder, "tv", StringComparison.Ordinal) ? TvGroupType.TV : - null; - - if (groupType is null) - { - return null; - } - - var key = $"group-{tvShowId.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; - if (_memoryCache.TryGetValue(key, out TvGroupCollection? group)) + var key = $"group-{episodeGroupId}-{seasonNumber.ToString(CultureInfo.InvariantCulture)}-{language}"; + if (_memoryCache.TryGetValue(key, out TvGroup? group)) { return group; } await EnsureClientConfigAsync().ConfigureAwait(false); - var series = await GetSeriesAsync(tvShowId, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false); - var episodeGroupId = series?.EpisodeGroups?.Results?.Find(g => g.Type == groupType)?.Id; - - if (episodeGroupId is null) - { - return null; - } - - group = await _tmDbClient.GetTvEpisodeGroupsAsync( + var groups = await _tmDbClient.GetTvEpisodeGroupsAsync( episodeGroupId, language: TmdbUtils.NormalizeLanguage(language, countryCode), cancellationToken: cancellationToken).ConfigureAwait(false); + group = groups?.Groups?.Find(s => s.Order == seasonNumber); if (group is not null) { Cache(key, group); @@ -254,20 +240,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> - /// Gets a movie from the TMDb API based on the tv show's TMDb id. + /// Gets an episode from the TMDb API based on the tv show's TMDb id. /// </summary> /// <param name="tvShowId">The tv show's TMDb id.</param> /// <param name="seasonNumber">The season number.</param> /// <param name="episodeNumber">The episode number.</param> - /// <param name="displayOrder">The display order.</param> + /// <param name="episodeGroup">The containing episode group.</param> /// <param name="language">The episode's language.</param> /// <param name="imageLanguages">A comma-separated list of image languages.</param> /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv episode information or null if not found.</returns> - public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, long episodeNumber, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) + public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, long episodeNumber, string? episodeGroup, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { - var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; + var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{episodeGroup}-{language}"; if (_memoryCache.TryGetValue(key, out TvEpisode? episode)) { return episode; @@ -275,12 +261,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); - var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false); - if (group is not null) + if (!string.IsNullOrWhiteSpace(episodeGroup)) { - var season = group.Groups?.Find(s => s.Order == seasonNumber); - // Episode order starts at 0 - var ep = season?.Episodes?.Find(e => e.Order == episodeNumber - 1); + var group = await GetSeriesGroupAsync(episodeGroup, seasonNumber, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false); + var ep = group?.Episodes?.Find(e => e.Order == episodeNumber - 1); if (ep is not null) { seasonNumber = ep.SeasonNumber; @@ -326,7 +310,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 +552,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,14 +644,14 @@ 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++) { var image = images[i]; var imageType = type; - var language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, requestLanguage); + var language = TmdbUtils.GetImageLanguage(image.Iso_639_1, image.Iso_3166_1, requestLanguage); // Return Backdrops with a language specified (it has text) as Thumb. if (imageType == ImageType.Backdrop && !string.IsNullOrEmpty(language)) @@ -678,6 +662,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/TmdbExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbExternalUrlProvider.cs index 8d9ec10c11..83bbf2f808 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbExternalUrlProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbExternalUrlProvider.cs @@ -5,6 +5,7 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.Tmdb.TV; using TMDbLib.Objects.TvShows; namespace MediaBrowser.Providers.Plugins.Tmdb; @@ -26,6 +27,11 @@ public class TmdbExternalUrlProvider : IExternalUrlProvider if (item.TryGetProviderId(MetadataProvider.Tmdb, out var externalId)) { yield return TmdbUtils.BaseTmdbUrl + $"tv/{externalId}"; + + if (item.TryGetProviderId(TmdbEpisodeGroupId.ProviderKey, out var episodeGroup)) + { + yield return TmdbUtils.BaseTmdbUrl + $"tv/{externalId}/episode_group/{episodeGroup}"; + } } break; @@ -47,6 +53,12 @@ public class TmdbExternalUrlProvider : IExternalUrlProvider yield return TmdbUtils.BaseTmdbUrl + $"tv/{seriesExternalId}/season/{seasonNumber}"; } } + + if (season.Series?.TryGetProviderId(TmdbEpisodeGroupId.ProviderKey, out var episodeGroup) == true + && season.TryGetProviderId(TmdbEpisodeGroupId.ProviderKey, out var group)) + { + yield return TmdbUtils.BaseTmdbUrl + $"tv/{seriesExternalId}/episode_group/{episodeGroup}/group/{group}"; + } } break; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index a002140d1e..c8db90799d 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; @@ -459,33 +464,53 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> - /// Adjusts the image's language code preferring the 5 letter language code eg. en-US. + /// Determines the language code to report for an image. /// </summary> - /// <param name="imageLanguage">The image's actual language code.</param> + /// <remarks> + /// TMDb keeps an image's region in iso_3166_1, which is what separates a pt-PT poster from a pt-BR + /// one. The region is added to the returned code only when it differs from the requested one, as in + /// a pt-PT image for a pt-BR request. TMDb files nearly every image under a region, so adding it + /// unconditionally would return en-US for an image that has to keep matching a plain "en" request. + /// </remarks> + /// <param name="imageLanguage">The image's ISO 639-1 language code.</param> + /// <param name="imageRegion">The image's ISO 3166-1 country code.</param> /// <param name="requestLanguage">The requested language code.</param> /// <returns>The language code.</returns> - public static string AdjustImageLanguage(string? imageLanguage, string requestLanguage) + public static string GetImageLanguage(string? imageLanguage, string? imageRegion, string? requestLanguage) { - if (string.IsNullOrEmpty(imageLanguage)) + // TMDb now returns xx for no language instead of an empty string. + if (string.IsNullOrEmpty(imageLanguage) || string.Equals(imageLanguage, "xx", StringComparison.OrdinalIgnoreCase)) { return string.Empty; } - if (!string.IsNullOrEmpty(requestLanguage) - && requestLanguage.Length > 2 - && imageLanguage.Length == 2 - && requestLanguage.StartsWith(imageLanguage, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrEmpty(requestLanguage)) { - return requestLanguage; + return imageLanguage; } - // TMDb now returns xx for no language instead of an empty string. - return string.Equals(imageLanguage, "xx", StringComparison.OrdinalIgnoreCase) - ? string.Empty - : imageLanguage; + var requestParts = requestLanguage.Split('-'); + + if (requestParts.Length != 2 || !string.Equals(requestParts[0], imageLanguage, StringComparison.OrdinalIgnoreCase)) + { + return imageLanguage; + } + + return string.IsNullOrEmpty(imageRegion) || string.Equals(imageRegion, requestParts[1], StringComparison.OrdinalIgnoreCase) + ? requestLanguage + : imageLanguage + "-" + imageRegion.ToUpperInvariant(); } /// <summary> + /// Determines whether the configured image size fetches the image at its original resolution. + /// An unset size falls back to <see cref="OriginalImageSize"/>, see TmdbClientManager.GetUrl. + /// </summary> + /// <param name="size">The configured image size.</param> + /// <returns><c>true</c> if the original image is fetched; otherwise, <c>false</c>.</returns> + public static bool IsOriginalImageSize(string? size) + => string.IsNullOrEmpty(size) || string.Equals(size, OriginalImageSize, StringComparison.OrdinalIgnoreCase); + + /// <summary> /// Combines the metadata country code and the parental rating from the API into the value we store in our database. /// </summary> /// <param name="countryCode">The ISO 3166-1 country code of the rating country.</param> diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index b350f482c3..8bec40c9c4 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -207,14 +207,57 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> } } + internal static HashSet<string> GetPresentEpisodeIds(IEnumerable<Episode> episodes) + { + var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); + foreach (var episode in episodes) + { + if (episode.IsVirtualItem || episode.IsMissingEpisode) + { + continue; + } + + foreach (var (provider, id) in episode.ProviderIds) + { + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + var key = IdentityKey(provider, id); + counts[key] = counts.GetValueOrDefault(key) + 1; + } + } + + return [.. counts.Where(e => e.Value == 1).Select(e => e.Key)]; + } + + internal static bool IsAlreadyPresent(Episode episode, HashSet<string> presentEpisodeIds) + { + foreach (var (provider, id) in episode.ProviderIds) + { + if (!string.IsNullOrWhiteSpace(id) && presentEpisodeIds.Contains(IdentityKey(provider, id))) + { + return true; + } + } + + return false; + } + + private static string IdentityKey(string provider, string id) => provider + "=" + id; + private void RemoveObsoleteEpisodes(Series series) { - var episodesBySeason = series.GetEpisodes(null, new DtoOptions(), true) + var episodes = series.GetEpisodes(null, new DtoOptions(), true) .OfType<Episode>() - .GroupBy(e => e.ParentIndexNumber) .ToList(); - foreach (var seasonEpisodes in episodesBySeason) + // Matched across the whole series, not per season: the orders disagree about which season an episode + // belongs to as readily as they disagree about its number. + var presentEpisodeIds = GetPresentEpisodeIds(episodes); + + foreach (var seasonEpisodes in episodes.GroupBy(e => e.ParentIndexNumber)) { List<Episode> nonPhysicalEpisodes = []; List<Episode> physicalEpisodes = []; @@ -233,26 +276,35 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> foreach (var episode in nonPhysicalEpisodes) { // Episodes without an episode number are practically orphaned and should be deleted - // Episodes with a physical equivalent should be deleted (they are no longer missing) - var shouldKeep = episode.IndexNumber.HasValue && !physicalEpisodes.Any(e => e.ContainsEpisodeNumber(episode.IndexNumber.Value)); + if (!episode.IndexNumber.HasValue) + { + DeleteEpisode(episode, "it has no episode number"); + continue; + } - if (shouldKeep) + // Episodes with a physical equivalent should be deleted (they are no longer missing) + if (physicalEpisodes.Any(e => e.ContainsEpisodeNumber(episode.IndexNumber.Value))) { + DeleteEpisode(episode, "a physical episode has that number"); continue; } - DeleteEpisode(episode); + if (IsAlreadyPresent(episode, presentEpisodeIds)) + { + DeleteEpisode(episode, "the same episode is already present under another number"); + } } } } - private void DeleteEpisode(Episode episode) + private void DeleteEpisode(Episode episode, string reason) { Logger.LogInformation( - "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}", + "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}: {Reason}", episode.ParentIndexNumber, episode.IndexNumber, - episode.SeriesName); + episode.SeriesName, + reason); LibraryManager.DeleteItem( episode, diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/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..6efced6556 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs @@ -45,8 +45,19 @@ 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 refresh the query planner statistics, e.g. after a library scan changed the data. + /// Unlike <see cref="RunScheduledOptimisation(CancellationToken)"/> it should not reclaim space, so that it stays + /// cheap enough to run after every scan. + /// </summary> + /// <param name="cancellationToken">The token to abort the operation.</param> + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + Task RefreshStatistics(CancellationToken cancellationToken) => Task.CompletedTask; + + /// <summary> + /// If supported this should perform any actions that are required on stopping the jellyfin server. This runs + /// against a deadline imposed by the service manager, so unlike + /// <see cref="RunScheduledOptimisation(CancellationToken)"/> it should only do work whose cost does not grow with + /// 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.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs index 29a073ff74..585ee82854 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs @@ -46,9 +46,9 @@ public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior TimeSpan.FromSeconds(3) ]; - Func<int, Context, TimeSpan> backoffProvider = (index, context) => + Func<int, Context, TimeSpan> backoffProvider = (retryNo, context) => { - var backoff = sleepDurations[index]; + var backoff = sleepDurations[retryNo - 1]; return backoff + TimeSpan.FromMilliseconds(RandomNumberGenerator.GetInt32(0, (int)(backoff.TotalMilliseconds * .5))); }; diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs index ee36be035b..9e846056ac 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs @@ -35,9 +35,11 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity> builder.HasIndex(e => e.Path); builder.HasIndex(e => e.ParentId); - builder.HasIndex(e => e.OwnerId); + builder.HasIndex(e => e.OwnerId) + .HasFilter("\"OwnerId\" IS NOT NULL"); builder.HasIndex(e => e.Name); - builder.HasIndex(e => new { e.ExtraType, e.OwnerId }); + builder.HasIndex(e => new { e.ExtraType, e.OwnerId }) + .HasFilter("\"ExtraType\" IS NOT NULL"); builder.HasIndex(e => e.PresentationUniqueKey); // covering index builder.HasIndex(e => new { e.TopParentId, e.Id }); 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..8c03347736 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs @@ -11,6 +11,74 @@ 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. + DROP TABLE IF EXISTS "DoomedUserDataKeys"; + CREATE TEMPORARY TABLE "DoomedUserDataKeys" ( + "UserId" TEXT NOT NULL, + "CustomDataKey" TEXT NOT NULL, + PRIMARY KEY ("UserId", "CustomDataKey")); + + -- Collect the colliding keys up front: correlating against "UserData" directly makes + -- the delete below re-scan every row of that user once per placeholder row. + INSERT OR IGNORE INTO "DoomedUserDataKeys" ("UserId", "CustomDataKey") + SELECT Doomed."UserId", Doomed."CustomDataKey" + FROM "UserData" AS Doomed + INNER JOIN "OrphanedBaseItemIds" AS Orphan ON Orphan."Id" = Doomed."ItemId"; + + DELETE FROM "UserData" + WHERE "ItemId" = '00000000-0000-0000-0000-000000000001' + AND EXISTS ( + SELECT 1 + FROM "DoomedUserDataKeys" AS Doomed + 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 "DoomedUserDataKeys"; + DROP TABLE "OrphanedBaseItemIds"; + """); + // Normalize OwnerId to uppercase GUID format migrationBuilder.Sql( @"UPDATE BaseItems @@ -51,41 +119,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations columns: new[] { "BaseItemEntityId", "Name", "OwnerId" }, values: new object[] { null, "This is a placeholder item for UserData that has been detached from its original item", null }); - migrationBuilder.CreateIndex( - name: "IX_BaseItems_BaseItemEntityId", - table: "BaseItems", - column: "BaseItemEntityId"); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_ExtraType", - table: "BaseItems", - column: "ExtraType"); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_ExtraType_OwnerId", - table: "BaseItems", - columns: new[] { "ExtraType", "OwnerId" }); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_OwnerId", - table: "BaseItems", - column: "OwnerId"); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated", - table: "BaseItems", - columns: new[] { "TopParentId", "IsFolder", "IsVirtualItem", "DateCreated" }); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated", - table: "BaseItems", - columns: new[] { "TopParentId", "MediaType", "IsVirtualItem", "DateCreated" }); - - migrationBuilder.CreateIndex( - name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated", - table: "BaseItems", - columns: new[] { "TopParentId", "Type", "IsVirtualItem", "DateCreated" }); - + // No CreateIndex calls here on purpose: AddForeignKey rebuilds BaseItems on SQLite and + // recreates every index of the target model afterwards, so building them first only + // pays for a full index pass that the rebuild immediately throws away. migrationBuilder.AddForeignKey( name: "FK_BaseItems_BaseItems_BaseItemEntityId", table: "BaseItems", diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/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/Migrations/20261002210711_MakeOwnerIdIndexesPartial.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20261002210711_MakeOwnerIdIndexesPartial.Designer.cs new file mode 100644 index 0000000000..4407809410 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20261002210711_MakeOwnerIdIndexesPartial.Designer.cs @@ -0,0 +1,1809 @@ +// <auto-generated /> +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20261002210711_MakeOwnerIdIndexesPartial")] + partial class MakeOwnerIdIndexesPartial + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Index") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<string>("Filename") + .HasColumnType("TEXT"); + + b.Property<string>("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Album") + .HasColumnType("TEXT"); + + b.Property<string>("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property<string>("Artists") + .HasColumnType("TEXT"); + + b.Property<int?>("Audio") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("CleanName") + .HasColumnType("TEXT"); + + b.Property<float?>("CommunityRating") + .HasColumnType("REAL"); + + b.Property<float?>("CriticRating") + .HasColumnType("REAL"); + + b.Property<string>("CustomRating") + .HasColumnType("TEXT"); + + b.Property<string>("Data") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("EndDate") + .HasColumnType("TEXT"); + + b.Property<string>("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property<int?>("ExtraType") + .HasColumnType("INTEGER"); + + b.Property<string>("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property<string>("Genres") + .HasColumnType("TEXT"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsLocked") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMovie") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsSeries") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property<float?>("LUFS") + .HasColumnType("REAL"); + + b.Property<string>("MediaType") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<float?>("NormalizationGain") + .HasColumnType("REAL"); + + b.Property<string>("OfficialRating") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasColumnType("TEXT"); + + b.Property<Guid?>("OwnerId") + .HasColumnType("TEXT"); + + b.Property<Guid?>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("PremiereDate") + .HasColumnType("TEXT"); + + b.Property<string>("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<Guid?>("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property<string>("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property<int?>("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property<long?>("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("SeasonId") + .HasColumnType("TEXT"); + + b.Property<string>("SeasonName") + .HasColumnType("TEXT"); + + b.Property<Guid?>("SeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesName") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<string>("ShowId") + .HasColumnType("TEXT"); + + b.Property<long?>("Size") + .HasColumnType("INTEGER"); + + b.Property<string>("SortName") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("StartDate") + .HasColumnType("TEXT"); + + b.Property<string>("Studios") + .HasColumnType("TEXT"); + + b.Property<string>("Tagline") + .HasColumnType("TEXT"); + + b.Property<string>("Tags") + .HasColumnType("TEXT"); + + b.Property<Guid?>("TopParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property<string>("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("UnratedType") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId") + .HasFilter("\"OwnerId\" IS NOT NULL"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId") + .HasFilter("\"ExtraType\" IS NOT NULL"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<byte[]>("Blurhash") + .HasColumnType("BLOB"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("ImageType") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property<string>("ImagePath") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<long>("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property<Guid>("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.Property<string>("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property<Guid>("ItemValueId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection<string>("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property<long>("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property<Guid>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ChildId") + .HasColumnType("TEXT"); + + b.Property<int>("ChildType") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("EndTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<long>("StartTicks") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property<string>("AspectRatio") + .HasColumnType("TEXT"); + + b.Property<float?>("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("BitDepth") + .HasColumnType("INTEGER"); + + b.Property<int?>("BitRate") + .HasColumnType("INTEGER"); + + b.Property<int?>("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<string>("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property<int?>("Channels") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property<string>("ColorSpace") + .HasColumnType("TEXT"); + + b.Property<string>("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<int?>("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvLevel") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvProfile") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property<int?>("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<bool?>("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAvc") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsDefault") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsExternal") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsForced") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + + b.Property<string>("KeyFrames") + .HasColumnType("TEXT"); + + b.Property<string>("Language") + .HasColumnType("TEXT"); + + b.Property<float?>("Level") + .HasColumnType("REAL"); + + b.Property<string>("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PixelFormat") + .HasColumnType("TEXT"); + + b.Property<string>("Profile") + .HasColumnType("TEXT"); + + b.Property<float?>("RealFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("RefFrames") + .HasColumnType("INTEGER"); + + b.Property<int?>("Rotation") + .HasColumnType("INTEGER"); + + b.Property<int?>("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("SampleRate") + .HasColumnType("INTEGER"); + + b.Property<int>("StreamType") + .HasColumnType("INTEGER"); + + b.Property<string>("TimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamType", "ItemId", "Language", "IsExternal"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("PeopleId") + .HasColumnType("TEXT"); + + b.Property<string>("Role") + .HasColumnType("TEXT"); + + b.Property<int?>("ListOrder") + .HasColumnType("INTEGER"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.HasIndex("PeopleId", "ItemId"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique(); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique(); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.Property<int>("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("Interval") + .HasColumnType("INTEGER"); + + b.Property<int>("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property<int>("TileHeight") + .HasColumnType("INTEGER"); + + b.Property<int>("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property<int?>("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property<bool?>("Likes") + .HasColumnType("INTEGER"); + + b.Property<int>("PlayCount") + .HasColumnType("INTEGER"); + + b.Property<long>("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("Played") + .HasColumnType("INTEGER"); + + b.Property<double?>("Rating") + .HasColumnType("REAL"); + + b.Property<DateTime?>("RetentionDate") + .HasColumnType("TEXT"); + + b.Property<int?>("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20261002210711_MakeOwnerIdIndexesPartial.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20261002210711_MakeOwnerIdIndexesPartial.cs new file mode 100644 index 0000000000..96aab1778c --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20261002210711_MakeOwnerIdIndexesPartial.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// <inheritdoc /> + public partial class MakeOwnerIdIndexesPartial : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_BaseItems_ExtraType_OwnerId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_OwnerId", + table: "BaseItems"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_ExtraType_OwnerId", + table: "BaseItems", + columns: new[] { "ExtraType", "OwnerId" }, + filter: "\"ExtraType\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_OwnerId", + table: "BaseItems", + column: "OwnerId", + filter: "\"OwnerId\" IS NOT NULL"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_BaseItems_ExtraType_OwnerId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_OwnerId", + table: "BaseItems"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_ExtraType_OwnerId", + table: "BaseItems", + columns: new[] { "ExtraType", "OwnerId" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_OwnerId", + table: "BaseItems", + column: "OwnerId"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs index 0c35ccc992..d3df73d2f6 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -365,7 +365,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("Name"); - b.HasIndex("OwnerId"); + b.HasIndex("OwnerId") + .HasFilter("\"OwnerId\" IS NOT NULL"); b.HasIndex("ParentId"); @@ -382,7 +383,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("SeriesName"); - b.HasIndex("ExtraType", "OwnerId"); + b.HasIndex("ExtraType", "OwnerId") + .HasFilter("\"ExtraType\" IS NOT NULL"); b.HasIndex("TopParentId", "Id"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index f11cde7e48..f0b44d6130 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; @@ -109,6 +110,35 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider } /// <inheritdoc/> + public async Task RefreshStatistics(CancellationToken cancellationToken) + { + if (DbContextFactory is null) + { + return; + } + + var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!await HasLibraryItemsAsync(context, cancellationToken).ConfigureAwait(false)) + { + return; + } + + _logger.LogInformation("Analyzing jellyfin.db"); + await AnalyzeAsync(context, cancellationToken).ConfigureAwait(false); + } + finally + { + await context.Database.CloseConnectionAsync().ConfigureAwait(false); + } + } + } + + /// <inheritdoc/> public void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); @@ -117,17 +147,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,13 +186,96 @@ 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.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + try + { + long? tempStore; + var pragmaCommand = context.Database.GetDbConnection().CreateCommand(); + await using (pragmaCommand.ConfigureAwait(false)) + { + pragmaCommand.CommandText = "PRAGMA temp_store"; + tempStore = 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); + } + } + + // Statistics taken while the library is empty make the planner treat every table as one row and + // pick full scans once it fills up; no statistics at all plan far better until there is data. + if (await HasLibraryItemsAsync(context, cancellationToken).ConfigureAwait(false)) + { + await AnalyzeAsync(context, cancellationToken).ConfigureAwait(false); + } + else + { + _logger.LogInformation("Not analyzing jellyfin.db, the library holds no items yet"); + } + + 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 Task<bool> HasLibraryItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + // Folders and the seeded placeholder exist before any library has been scanned. + return context.BaseItems.AnyAsync(e => !e.IsFolder && e.Type != "PLACEHOLDER", cancellationToken); + } + + private static async Task AnalyzeAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + long? analysisLimit; + var pragmaCommand = context.Database.GetDbConnection().CreateCommand(); + await using (pragmaCommand.ConfigureAwait(false)) + { + pragmaCommand.CommandText = "PRAGMA analysis_limit"; + analysisLimit = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false); + } + + await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false); + try + { await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); - _logger.LogInformation("jellyfin.db optimized successfully!"); } + finally + { + // The connection goes back to the pool, so hand it over the way it was handed to us. + if (analysisLimit is not null) + { + await context.Database.ExecuteSqlRawAsync( + FormattableString.Invariant($"PRAGMA analysis_limit={analysisLimit.Value}"), + CancellationToken.None).ConfigureAwait(false); + } + } + } + + private static async Task<long?> ReadPragmaValueAsync(DbCommand command, CancellationToken cancellationToken) + { + var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return value is null or DBNull ? null : Convert.ToInt64(value, CultureInfo.InvariantCulture); } /// <inheritdoc/> @@ -157,16 +285,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 path = GetDatabasePath(); + 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 /> @@ -174,7 +317,7 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider { // ensure there are absolutely no dangling Sqlite connections. SqliteConnection.ClearAllPools(); - var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); + var path = GetDatabasePath(); var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName, $"{key}_jellyfin.db"); if (!File.Exists(backupFile)) @@ -183,10 +326,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) { @@ -202,6 +390,17 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider return Task.CompletedTask; } + private string GetDatabasePath() + { + if (DbContextFactory is null) + { + throw new InvalidOperationException("The database provider has not been initialized."); + } + + using var dbContext = DbContextFactory.CreateDbContext(); + return dbContext.Database.GetDbConnection().DataSource; + } + /// <inheritdoc/> public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames) { 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.Api.Tests/Controllers/DynamicHlsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs index 5f5f273f12..a9f85739bc 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs +++ b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Controllers; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -11,6 +12,34 @@ namespace Jellyfin.Api.Tests.Controllers public class DynamicHlsControllerTests { [Theory] + [InlineData(8, 1, "smpte2084", false, "hvc1")] + [InlineData(8, 1, "smpte2084", true, "hvc1")] + [InlineData(8, 2, "bt709", false, "hvc1")] + [InlineData(8, 4, "arib-std-b67", false, "hvc1")] + [InlineData(8, 6, "smpte2084", false, "hvc1")] + [InlineData(5, 0, "smpte2084", false, "dvh1")] + [InlineData(7, 6, "smpte2084", false, "dvh1")] + [InlineData(null, null, "smpte2084", false, "dvh1")] + public void GetDolbyVisionHevcCodecTag_SelectsTagForProfile(int? profile, int? compatibilityId, string transfer, bool hdr10Plus, string expected) + { + var stream = new MediaStream + { + Codec = "hevc", + Type = MediaStreamType.Video, + RpuPresentFlag = 1, + BlPresentFlag = 1, + ColorSpace = "bt2020nc", + ColorPrimaries = "bt2020", + ColorTransfer = transfer, + DvProfile = profile, + DvBlSignalCompatibilityId = compatibilityId, + Hdr10PlusPresentFlag = hdr10Plus + }; + + Assert.Equal(expected, DynamicHlsController.GetDolbyVisionHevcCodecTag(stream)); + } + + [Theory] [MemberData(nameof(GetSegmentLengths_Success_TestData))] public void GetSegmentLengths_Success(long runtimeTicks, int segmentlength, double[] expected) { diff --git a/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs index 1a91efe4f2..7b37550e29 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs +++ b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs @@ -4,9 +4,11 @@ using Jellyfin.Api.Controllers; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using Moq; @@ -14,12 +16,21 @@ using Xunit; namespace Jellyfin.Api.Tests.Controllers; -public class ItemUpdateControllerTests +[Collection("LibraryManagerTests")] +public sealed class ItemUpdateControllerTests : IDisposable { private readonly ItemUpdateController _subject; + private readonly ILibraryManager? _previousLibraryManager; + private readonly ILocalizationManager? _previousLocalizationManager; public ItemUpdateControllerTests() { + // Children are scored and saved through BaseItem's process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousLocalizationManager = BaseItem.LocalizationManager; + BaseItem.LibraryManager = Mock.Of<ILibraryManager>(); + BaseItem.LocalizationManager = Mock.Of<ILocalizationManager>(); + _subject = new ItemUpdateController( Mock.Of<IFileSystem>(), Mock.Of<ILibraryManager>(), @@ -70,6 +81,75 @@ public class ItemUpdateControllerTests Assert.Equal("tt1234567", movie.ProviderIds["Imdb"]); } + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.LocalizationManager = _previousLocalizationManager!; + } + + [Fact] + public async Task UpdateItem_SeriesRatingUnchanged_KeepsEpisodeRatings() + { + // Regression test for https://github.com/jellyfin/jellyfin/issues/18241 + var (series, season, episodes) = CreateSeries("TV-PG", "TV-PG", "TV-14", null); + + await InvokeUpdateItem(new BaseItemDto { Overview = "New overview", OfficialRating = "TV-PG" }, series); + + Assert.Equal("TV-PG", season.OfficialRating); + Assert.Equal("TV-14", episodes[1].OfficialRating); + Assert.Null(episodes[2].OfficialRating); + } + + [Fact] + public async Task UpdateItem_SeriesRatingChanged_PropagatesOnlyToChildrenFollowingIt() + { + var (series, season, episodes) = CreateSeries("TV-PG", "TV-PG", "TV-14", null); + var locked = new Episode { OfficialRating = "TV-PG", PreferredMetadataCountryCode = "us", LockedFields = [MetadataField.OfficialRating] }; + season.Children = [.. episodes, locked]; + + await InvokeUpdateItem(new BaseItemDto { OfficialRating = "TV-MA" }, series); + + Assert.Equal("TV-MA", season.OfficialRating); + Assert.Equal("TV-MA", episodes[0].OfficialRating); + Assert.Equal("TV-14", episodes[1].OfficialRating); + Assert.Equal("TV-MA", episodes[2].OfficialRating); + Assert.Equal("TV-PG", locked.OfficialRating); + } + + [Fact] + public async Task UpdateItem_SeriesCustomRatingChanged_PropagatesIt() + { + var (series, season, episodes) = CreateSeries("TV-PG", "TV-PG", "TV-14", null); + episodes[0].CustomRating = "TV-Y"; + + await InvokeUpdateItem(new BaseItemDto { OfficialRating = "TV-PG", CustomRating = "XXX" }, series); + + Assert.Equal("XXX", season.CustomRating); + Assert.Equal("TV-Y", episodes[0].CustomRating); + Assert.Equal("XXX", episodes[1].CustomRating); + Assert.Equal("TV-14", episodes[1].OfficialRating); + } + + [Fact] + public async Task UpdateItem_SeasonRatingChanged_KeepsEpisodesWithTheirOwnRating() + { + var (_, season, episodes) = CreateSeries("TV-PG", "TV-PG", "TV-14", null); + + await InvokeUpdateItem(new BaseItemDto { OfficialRating = "TV-MA" }, season); + + Assert.Equal("TV-MA", episodes[0].OfficialRating); + Assert.Equal("TV-14", episodes[1].OfficialRating); + Assert.Equal("TV-MA", episodes[2].OfficialRating); + } + + private static (Series Series, Season Season, Episode[] Episodes) CreateSeries(string seriesRating, params string?[] episodeRatings) + { + var episodes = Array.ConvertAll(episodeRatings, r => new Episode { OfficialRating = r, PreferredMetadataCountryCode = "us" }); + var season = new Season { OfficialRating = seriesRating, PreferredMetadataCountryCode = "us", Children = episodes }; + var series = new Series { OfficialRating = seriesRating, PreferredMetadataCountryCode = "us", Children = [season] }; + return (series, season, episodes); + } + private Task InvokeUpdateItem(BaseItemDto request, BaseItem item) { return _subject.UpdateItem(request, item); diff --git a/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs new file mode 100644 index 0000000000..77321f0b05 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Controllers; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Subtitles; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +public class SubtitleControllerTests +{ + private const int SubtitleIndex = 2; + + private static readonly Guid _itemId = new("6e1f0a3b7c8d4e5f9a0b1c2d3e4f5a6b"); + + [Theory] + // Graphical subtitle tracks are negotiated as SubtitleDeliveryMethod.External and must be handed + // to the client untouched, because neither format can be parsed or rewritten. + [InlineData("pgssub", "pgssub", "/cache/subs/sub.sup")] + // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub is exposed as .mks + // while the negotiated profile format stays "vobsub". + [InlineData("vobsub", "dvdsub", "/cache/subs/sub.mks")] + public async Task GetSubtitle_GraphicalStream_ReturnsRawFileWithRangeProcessing(string format, string codec, string path) + { + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController(codec, path, encoder, out var fileSystem); + fileSystem.Setup(f => f.FileExists(path)).Returns(true); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, format, null, null, null, null, null); + + var fileResult = Assert.IsType<PhysicalFileResult>(result); + Assert.Equal(path, fileResult.FileName); + Assert.True(fileResult.EnableRangeProcessing); + encoder.Verify( + e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<long>(), It.IsAny<long>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), + Times.Never); + } + + [Fact] + public async Task GetSubtitle_RemotelyHostedGraphicalStream_FallsBackToEncoder() + { + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController("pgssub", "https://example.com/sub.sup", encoder, out var fileSystem); + fileSystem.Setup(f => f.FileExists(It.IsAny<string>())).Returns(false); + encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "pgssub", 0, 0, false, It.IsAny<CancellationToken>())) + .ReturnsAsync(new MemoryStream([1, 2, 3])); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "pgssub", null, null, null, null, null); + + Assert.IsType<FileStreamResult>(result); + } + + [Fact] + public async Task GetSubtitle_VobSubIdxPair_FallsBackToEncoder() + { + // A .idx/.sub pair has no raw payload the client could render: the encoder converts it. + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController("dvdsub", "/media/sub.idx", encoder, out _); + encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "vobsub", 0, 0, false, It.IsAny<CancellationToken>())) + .ReturnsAsync(new MemoryStream([1, 2, 3])); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "vobsub", null, null, null, null, null); + + Assert.IsType<FileStreamResult>(result); + } + + [Fact] + public async Task GetSubtitle_TextStream_IsEncoded() + { + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController("subrip", "/media/sub.srt", encoder, out _); + encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "vtt", 0, 0, false, It.IsAny<CancellationToken>())) + .ReturnsAsync(new MemoryStream([1, 2, 3])); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "vtt", null, null, null, null, null); + + Assert.IsType<FileStreamResult>(result); + } + + private static SubtitleController CreateController( + string codec, + string subtitlePath, + Mock<ISubtitleEncoder> subtitleEncoder, + out Mock<IFileSystem> fileSystem) + { + var item = new Movie { Id = _itemId }; + var subtitleStream = new MediaStream + { + Type = MediaStreamType.Subtitle, + Index = SubtitleIndex, + Codec = codec, + Path = subtitlePath, + IsExternal = true + }; + + var mediaSource = new MediaSourceInfo + { + Id = _itemId.ToString("N"), + MediaStreams = new List<MediaStream> { subtitleStream } + }; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(l => l.GetItemById<BaseItem>(_itemId)).Returns(item); + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(m => m.GetStaticMediaSources(item, false, null)).Returns([mediaSource]); + + subtitleEncoder.Setup(e => e.GetSubtitleFilePath(subtitleStream, mediaSource, It.IsAny<CancellationToken>())) + .ReturnsAsync(subtitlePath); + + fileSystem = new Mock<IFileSystem>(); + + return new SubtitleController( + Mock.Of<IServerConfigurationManager>(), + libraryManager.Object, + Mock.Of<ISubtitleManager>(), + subtitleEncoder.Object, + mediaSourceManager.Object, + Mock.Of<IProviderManager>(), + fileSystem.Object, + NullLogger<SubtitleController>.Instance); + } +} diff --git a/tests/Jellyfin.Controller.Tests/Entities/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..c67717e2fa 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,70 @@ 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 previousMediaSourceManager = BaseItem.MediaSourceManager; + var library = new Mock<ILibraryManager>(MockBehavior.Strict); + var repository = new Mock<MediaBrowser.Controller.Persistence.IItemRepository>(MockBehavior.Strict); + var directory = new Mock<IDirectoryService>(); + directory.Setup(d => d.IsAccessible(It.IsAny<string>())).Returns(true); + + // IsLibraryFolderAccessible reads FileNameWithoutExtension, which resolves the path protocol + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + try + { + BaseItem.LibraryManager = library.Object; + BaseItem.ItemRepository = repository.Object; + BaseItem.Logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<BaseItem>.Instance; + BaseItem.MediaSourceManager = mediaSourceManager.Object; + var folder = new FailingEnumerationFolder(failAfterFirstChild, accessDenied) + { + Id = Guid.NewGuid(), + 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; + BaseItem.MediaSourceManager = previousMediaSourceManager; + } + } + + [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 +248,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 +290,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 +736,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..1794469359 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs @@ -0,0 +1,91 @@ +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 Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities; + +[Collection("LibraryManagerTests")] +public sealed class PlaylistTests : IDisposable +{ + private readonly ILibraryManager? _previousLibraryManager = BaseItem.LibraryManager; + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager; + } + + [Fact] + public void IsVisible_PlaylistWithNothingLeftInIt_IsHidden() + { + // The SQL parental filter hides a container whose every member is blocked, so a listing + // built in memory has to reach the same answer. + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + SetupLibrary(blocked); + + Assert.False(BuildPlaylist(blocked).IsVisible(BuildRestrictedUser())); + } + + [Fact] + public void IsVisible_PlaylistWithOneAllowedItem_StaysVisible() + { + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + var allowed = new Audio { Id = Guid.NewGuid(), Name = "Song" }; + SetupLibrary(blocked, allowed); + + Assert.True(BuildPlaylist(blocked, allowed).IsVisible(BuildRestrictedUser())); + } + + [Fact] + public void IsVisible_UnrestrictedUser_LeavesTheItemsUnresolved() + { + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + var libraryManager = SetupLibrary(blocked); + var user = new User("user", "auth-provider", "reset-provider"); + + Assert.True(BuildPlaylist(blocked).IsVisible(user)); + + // Resolving a playlist's items is a query per playlist; nothing may run it for a user no + // rating keeps anything from. + libraryManager.Verify(x => x.GetItemList(It.IsAny<InternalItemsQuery>()), Times.Never); + } + + private static Mock<ILibraryManager> SetupLibrary(params BaseItem[] items) + { + var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(items); + BaseItem.LibraryManager = libraryManager.Object; + + return libraryManager; + } + + private static Playlist BuildPlaylist(params BaseItem[] items) + { + // An empty path keeps the playlist out of the shared-playlist branch. + return new Playlist + { + Id = Guid.NewGuid(), + Name = "Playlist", + LinkedChildren = items.Select(LinkedChild.Create).ToArray() + }; + } + + private static User BuildRestrictedUser() + { + var user = new User("user", "auth-provider", "reset-provider") { MaxParentalRatingScore = 5 }; + user.SetPreference(PreferenceKind.BlockUnratedItems, new[] { UnratedItem.Movie }); + + return user; + } +} diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs index 2b009b4673..aca55fbcc1 100644 --- a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs @@ -261,6 +261,66 @@ public class EncodingHelperTests Assert.Contains("-ar 48000", args, StringComparison.Ordinal); } + [Fact] + public void GetProgressiveAudioFullCommandLine_StereoDownmix_AppliesDownMixAlgorithm() + { + // Issue #18194: -ac 2 alone drops the LFE channel, the configured downmix filter must be applied. + var state = BuildAudioState("aac", 48000); + state.AudioStream.Channels = 6; + state.AudioStream.ChannelLayout = "5.1"; + state.OutputAudioChannels = 2; + var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750, DownMixAudioBoost = 1 }; + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, options, "/tmp/out"); + + Assert.Contains( + "-af \"" + DownMixAlgorithmsHelper.AlgorithmFilterStrings[(DownMixStereoAlgorithms.Dave750, "5.1")] + "\"", + args, + StringComparison.Ordinal); + } + + [Fact] + public void GetProgressiveAudioFullCommandLine_NoDownmix_EmitsNoAudioFilter() + { + var state = BuildAudioState("aac", 48000); + state.AudioStream.Channels = 2; + state.OutputAudioChannels = 2; + var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 }; + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, options, "/tmp/out"); + + Assert.DoesNotContain("-af", args, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1)] + [InlineData(6)] + public void GetProgressiveVideoAudioArguments_NonStereoOutput_KeepsChannelCount(int outputChannels) + { + // The downmix filter only applies to stereo output, so -ac must not be dropped otherwise. + var state = BuildAudioState("aac", 48000); + state.AudioStream.Channels = 6; + state.AudioStream.ChannelLayout = "5.1"; + state.OutputAudioChannels = outputChannels; + var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 }; + var args = CreateHelper().GetProgressiveVideoAudioArguments(state, options); + + Assert.Contains("-ac " + outputChannels, args, StringComparison.Ordinal); + Assert.DoesNotContain("pan=", args, StringComparison.Ordinal); + } + + [Fact] + public void GetProgressiveVideoAudioArguments_StereoDownmix_UsesFilterInsteadOfChannelCount() + { + var state = BuildAudioState("aac", 48000); + state.AudioStream.Channels = 6; + state.AudioStream.ChannelLayout = "5.1"; + state.OutputAudioChannels = 2; + var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 }; + var args = CreateHelper().GetProgressiveVideoAudioArguments(state, options); + + Assert.DoesNotContain("-ac ", args, StringComparison.Ordinal); + Assert.Contains("pan=stereo", args, StringComparison.Ordinal); + } + private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate, string? outputContainer = null) { var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 }; diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/TranscodingJobTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/TranscodingJobTests.cs new file mode 100644 index 0000000000..9dc279a5b2 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/TranscodingJobTests.cs @@ -0,0 +1,56 @@ +using System; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Controller.Tests.MediaEncoding; + +public class TranscodingJobTests +{ + private static TranscodingJob CreateJob() + => new TranscodingJob(NullLogger<TranscodingJob>.Instance); + + [Fact] + public void GetHighestServedSegmentIndexEndingAtOrBefore_NoSegmentsServed_ReturnsNull() + { + using var job = CreateJob(); + + Assert.Null(job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromHours(1).Ticks)); + } + + [Fact] + public void GetHighestServedSegmentIndexEndingAtOrBefore_InitSegment_IsIgnored() + { + using var job = CreateJob(); + job.ReportSegmentDownloaded(-1, 0); + + Assert.Null(job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromHours(1).Ticks)); + } + + [Fact] + public void GetHighestServedSegmentIndexEndingAtOrBefore_SegmentEndingExactlyAtPosition_IsIncluded() + { + using var job = CreateJob(); + job.ReportSegmentDownloaded(0, TimeSpan.FromSeconds(6).Ticks); + job.ReportSegmentDownloaded(1, TimeSpan.FromSeconds(12).Ticks); + + Assert.Equal(1, job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromSeconds(12).Ticks)); + Assert.Equal(0, job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromSeconds(11.9).Ticks)); + } + + [Fact] + public void GetHighestServedSegmentIndexEndingAtOrBefore_SegmentsLongerThanDesired_ReturnsServedIndex() + { + // Keyframe-based playlists average longer segments than the desired 6s. + const double SegmentSeconds = 6.6; + using var job = CreateJob(); + for (var i = 0; i < 520; i++) + { + job.ReportSegmentDownloaded(i, TimeSpan.FromSeconds((i + 1) * SegmentSeconds).Ticks); + } + + var keepFrom = TimeSpan.FromSeconds((520 * SegmentSeconds) - 120); + + Assert.Equal(500, job.GetHighestServedSegmentIndexEndingAtOrBefore(keepFrom.Ticks)); + } +} 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/SeriesPathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs index e6b0409db7..4e1de22790 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs @@ -25,5 +25,15 @@ namespace Jellyfin.Naming.Tests.TV Assert.Equal(name, res.SeriesName); Assert.True(res.Success); } + + [Theory] + [InlineData("/media/Jujutsu Kaisen (BD_1280x720)")] + [InlineData("/media/Show.1920x1080.BluRay")] + public void SeriesPathParser_ResolutionPatternIsNotASeries(string path) + { + var res = SeriesPathParser.Parse(_namingOptions, path); + + Assert.False(res.Success); + } } } diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs index b81b7934cd..0c44595441 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs @@ -20,6 +20,13 @@ 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")] + [InlineData("/media/Bunker.S03.1080p.PULSAR.WEB-DL.DDP5.1.Atmos.H.264-showWEB", "Bunker")] + [InlineData("/media/Outer.Colony.S01.1080p.NOVA.WEB-DL.DDP5.1.H.264.HUN.ENG-QUASAR", "Outer Colony")] public void SeriesResolverResolveTest(string path, string name) { var res = SeriesResolver.Resolve(_namingOptions, path); diff --git a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs index 6d887c5771..fd93532ba6 100644 --- a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs @@ -32,6 +32,7 @@ namespace Jellyfin.Naming.Tests.Video public void TestExpandedExtras() { Test("trailer.mp4", ExtraType.Trailer); + Test("trailer2.mp4", ExtraType.Trailer); Test("trailer.mp3", null); Test("300-trailer.mp4", ExtraType.Trailer); Test("stuff trailerthings.mkv", null); @@ -133,6 +134,8 @@ namespace Jellyfin.Naming.Tests.Video Test("300.sample.mp4", ExtraType.Sample); Test("300_sample.mp4", ExtraType.Sample); Test("300 - sample.mp4", ExtraType.Sample); + Test("sample1.mp4", ExtraType.Sample); + Test("Sample2.mkv", ExtraType.Sample); } [Fact] 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/Books/EpubUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Books/EpubUtilsTests.cs new file mode 100644 index 0000000000..a5a3af6210 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Books/EpubUtilsTests.cs @@ -0,0 +1,34 @@ +using System.IO; +using System.IO.Compression; +using System.Text; +using MediaBrowser.Providers.Books.OpenPackagingFormat; +using Xunit; + +namespace Jellyfin.Providers.Tests.Books; + +public class EpubUtilsTests +{ + [Fact] + public void ReadContentFilePath_ReadsRootFileFromContainer() + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true)) + { + using var writer = new StreamWriter(archive.CreateEntry("META-INF/container.xml").Open(), Encoding.UTF8); + writer.Write( + """ + <?xml version="1.0"?> + <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"> + <rootfiles> + <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/> + </rootfiles> + </container> + """); + } + + stream.Position = 0; + using var epub = new ZipArchive(stream, ZipArchiveMode.Read); + + Assert.Equal("OEBPS/content.opf", EpubUtils.ReadContentFilePath(epub)); + } +} diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs index 1ec859223e..e234e07fb0 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -177,6 +177,37 @@ namespace Jellyfin.Providers.Tests.Manager } [Theory] + [InlineData(ImageType.Primary, 1)] + [InlineData(ImageType.Backdrop, 2)] + public void MergeImages_StoredTimeTruncatedToMicroseconds_NoChange(ImageType imageType, int imageCount) + { + // Regression test for https://github.com/jellyfin/jellyfin/issues/18274 + var fileTime = new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddTicks(1234567); + var storedTime = fileTime.AddTicks(-(fileTime.Ticks % 10)); + + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>())) + .Returns(fileTime); + BaseItem.FileSystem = fileSystem.Object; + + var item = GetItemWithImages(imageType, imageCount, true); + foreach (var image in item.GetImages(imageType)) + { + image.DateModified = storedTime; + image.Height = 1; + image.Width = 1; + } + + var images = GetImages(imageType, imageCount, true); + + var itemImageProvider = GetItemImageProvider(null, fileSystem); + var changed = itemImageProvider.MergeImages(item, images, new ImageRefreshOptions(Mock.Of<IDirectoryService>())); + + Assert.False(changed); + Assert.All(item.GetImages(imageType), image => Assert.Equal(1, image.Width)); + } + + [Theory] [InlineData(ImageType.Primary, 0)] [InlineData(ImageType.Primary, 1)] [InlineData(ImageType.Backdrop, 2)] @@ -557,6 +588,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/AtlFlacRegressionTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/AtlFlacRegressionTests.cs new file mode 100644 index 0000000000..75ca0233f3 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/AtlFlacRegressionTests.cs @@ -0,0 +1,26 @@ +using System.IO; +using ATL; +using Xunit; + +namespace Jellyfin.Providers.Tests.MediaInfo; + +public class AtlFlacRegressionTests +{ + [Fact(Timeout = 5000)] + public void AtlParser_MalformedFlac_DoesNotHang() + { + // The 456-byte Vorbis comment block claims a 1024-byte vendor string. + byte[] fixture = [ + (byte)'f', (byte)'L', (byte)'a', (byte)'C', + 0, 0, 0, 34, + .. new byte[34], + 0x84, 0, 1, 200, + 0, 4, 0, 0, + .. new byte[452] + ]; + + // ATL 7.16 hangs on this stream. + using var stream = new MemoryStream(fixture); + _ = new Track(stream, ".flac"); + } +} 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/Music/MusicBrainzQueryExtensionsTests.cs b/tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs new file mode 100644 index 0000000000..6b0d3a2ab2 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Providers.Plugins.MusicBrainz; +using MetaBrainz.Common; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Providers.Tests.Music; + +public static class MusicBrainzQueryExtensionsTests +{ + [Fact] + public static async Task RetryOnTransientErrorAsync_ServerBusy_RetriesAndSucceeds() + { + var attempts = 0; + + var result = await MusicBrainzQueryExtensions.RetryOnTransientErrorAsync( + async _ => + { + attempts++; + if (attempts == 1) + { + throw await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero); + } + + return "found"; + }, + "test", + NullLogger.Instance, + CancellationToken.None); + + Assert.Equal("found", result); + Assert.Equal(2, attempts); + } + + [Fact] + public static async Task RetryOnTransientErrorAsync_ServerStaysBusy_GivesUp() + { + var attempts = 0; + + var error = await Assert.ThrowsAsync<HttpError>(() => MusicBrainzQueryExtensions.RetryOnTransientErrorAsync<string>( + async _ => + { + attempts++; + throw await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero); + }, + "test", + NullLogger.Instance, + CancellationToken.None)); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, error.Status); + Assert.Equal(3, attempts); + } + + [Fact] + public static async Task RetryOnTransientErrorAsync_NotFound_DoesNotRetry() + { + var attempts = 0; + + await Assert.ThrowsAsync<HttpError>(() => MusicBrainzQueryExtensions.RetryOnTransientErrorAsync<string>( + async _ => + { + attempts++; + throw await CreateErrorAsync(HttpStatusCode.NotFound, null); + }, + "test", + NullLogger.Instance, + CancellationToken.None)); + + Assert.Equal(1, attempts); + } + + [Fact] + public static async Task GetRetryDelay_NoHint_BacksOffExponentially() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, null); + + Assert.Equal(TimeSpan.FromSeconds(1), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + Assert.Equal(TimeSpan.FromSeconds(2), MusicBrainzQueryExtensions.GetRetryDelay(error, 2)); + } + + [Fact] + public static async Task GetRetryDelay_RetryAfterZero_WaitsMinimum() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero); + + Assert.Equal(TimeSpan.FromSeconds(1), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + } + + [Fact] + public static async Task GetRetryDelay_RetryAfterLongerThanBackoff_UsesRetryAfter() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.FromSeconds(10)); + + Assert.Equal(TimeSpan.FromSeconds(10), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + } + + [Fact] + public static async Task GetRetryDelay_LongRetryAfter_IsCapped() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.FromHours(1)); + + Assert.Equal(TimeSpan.FromSeconds(15), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + } + + [Fact] + public static async Task GetRetryDelay_RateLimitWindow_WaitsForReset() + { + var error = await CreateErrorAsync( + HttpStatusCode.ServiceUnavailable, + null, + headers => headers.TryAddWithoutValidation("X-RateLimit-Reset", DateTimeOffset.UtcNow.AddSeconds(8).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture))); + + var delay = MusicBrainzQueryExtensions.GetRetryDelay(error, 1); + + Assert.InRange(delay, TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(8)); + } + + private static async Task<HttpError> CreateErrorAsync(HttpStatusCode status, TimeSpan? retryAfter, Action<HttpResponseHeaders>? configureHeaders = null) + { + using var response = new HttpResponseMessage(status); + if (retryAfter is not null) + { + response.Headers.RetryAfter = new RetryConditionHeaderValue(retryAfter.Value); + } + + configureHeaders?.Invoke(response.Headers); + + return await HttpError.FromResponseAsync(response); + } +} diff --git a/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs b/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs index 3062cb7b42..0275d13242 100644 --- a/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs +++ b/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs @@ -63,6 +63,41 @@ namespace Jellyfin.Providers.Tests.Omdb Assert.Equal(expected, result); } + [Theory] + [InlineData("\"Vincent D'Onofrio\"", "Vincent D'Onofrio")] + [InlineData("\"Alan Moore 'Swamp Thing'\"", "Alan Moore 'Swamp Thing'")] + [InlineData("\"Tom & Jerry\"", "Tom & Jerry")] + [InlineData("\"Mail <a@b.com> for a translation\"", "Mail <a@b.com> for a translation")] + [InlineData("\"Elisa Mont\\u00e9s\"", "Elisa Montés")] + [InlineData("\"R&D and 100% & more\"", "R&D and 100% & more")] + public void Deserialize_Html_Encoded_String_Is_Decoded(string input, string expected) + { + var result = JsonSerializer.Deserialize<string?>(input, _options); + Assert.Equal(expected, result); + } + + [Fact] + public void Deserialize_Html_Encoded_Credits_Are_Decoded() + { + const string Input = "{\"Title\":\"Full Metal Jacket\",\"Actors\":\"Matthew Modine, R. Lee Ermey, Vincent D'Onofrio\",\"Response\":\"True\"}"; + var rootObject = JsonSerializer.Deserialize<OmdbProvider.RootObject>(Input, _options); + Assert.NotNull(rootObject); + Assert.Equal("Matthew Modine, R. Lee Ermey, Vincent D'Onofrio", rootObject!.Actors); + } + + [Fact] + public void ProviderJsonOptions_Take_Priority_Over_The_Defaults() + { + // The default string converter handles string as well, so appending the OMDb ones would never run them + var options = OmdbProvider.CreateJsonOptions(); + + const string Input = "{\"Title\":\"Full Metal Jacket\",\"Actors\":\"Vincent D'Onofrio\",\"Awards\":\"N/A\",\"Response\":\"True\"}"; + var rootObject = JsonSerializer.Deserialize<OmdbProvider.RootObject>(Input, options); + Assert.NotNull(rootObject); + Assert.Equal("Vincent D'Onofrio", rootObject!.Actors); + Assert.Null(rootObject.Awards); + } + [Fact] public void Roundtrip_Valid_Success() { diff --git a/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs new file mode 100644 index 0000000000..a73ed61a75 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Playlists; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.Playlists; + +public sealed class PlaylistItemsProviderTests : IDisposable +{ + private const string AccentedFolder = "Música épica"; + private const string AccentedSong = "Canción.mp3"; + private const string AsciiSong = "Song.mp3"; + + private readonly string _libraryRoot; + private readonly PlaylistItemsProvider _sut; + + public PlaylistItemsProviderTests() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + _libraryRoot = Path.Combine(Path.GetTempPath(), "jellyfin-playlist-tests", Guid.NewGuid().ToString("N")); + var mediaFolder = Path.Combine(_libraryRoot, AccentedFolder); + Directory.CreateDirectory(mediaFolder); + File.WriteAllText(Path.Combine(mediaFolder, AccentedSong), string.Empty); + File.WriteAllText(Path.Combine(mediaFolder, AsciiSong), string.Empty); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(m => m.FindByPath(It.IsAny<string>(), It.IsAny<bool?>())) + .Returns((string path, bool? _) => new Audio { Id = Guid.NewGuid(), Path = path }); + + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(m => m.MakeAbsolutePath(It.IsAny<string>(), It.IsAny<string>())) + .Returns((string folderPath, string filePath) => Path.GetFullPath(Path.Combine(folderPath, filePath))); + + _sut = new PlaylistItemsProvider(NullLogger<PlaylistItemsProvider>.Instance, libraryManager.Object, fileSystem.Object); + } + + [Fact] + public void GetM3uItems_Utf8Entries_ResolvesAccentedPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}"], 1); + + /// <summary> + /// Playlists written by Windows media players default to the local codepage rather than UTF-8. + /// Decoding those as UTF-8 mangles every accented character and loses the entry. + /// </summary> + [Fact] + public void GetM3uItems_LegacyCodepageEntries_ResolvesAccentedPaths() + => AssertResolved(Encoding.GetEncoding(1252), [$"{AccentedFolder}/{AccentedSong}"], 1); + + /// <summary> + /// The files on disk are stored precomposed (NFC), the playlist references them decomposed (NFD). + /// </summary> + [Fact] + public void GetM3uItems_DecomposedEntries_ResolvesAccentedPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}".Normalize(NormalizationForm.FormD)], 1); + + [Fact] + public void GetM3uItems_AsciiEntries_ResolvesPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AsciiSong}"], 1); + + [Fact] + public void GetM3uItems_Utf16Entries_ResolvesAccentedPaths() + => AssertResolved(Encoding.Unicode, [$"{AccentedFolder}/{AccentedSong}"], 1); + + [Fact] + public void GetM3uItems_MixedEntries_ResolvesEveryEntry() + => AssertResolved( + Encoding.GetEncoding(1252), + [$"{AccentedFolder}/{AccentedSong}", $"{AccentedFolder}/{AsciiSong}"], + 2); + + [Fact] + public void GetM3uItems_MissingFile_ResolvesNothing() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/Does not exist.mp3"], 0); + + public void Dispose() + { + if (Directory.Exists(_libraryRoot)) + { + Directory.Delete(_libraryRoot, true); + } + } + + private void AssertResolved(Encoding encoding, string[] entries, int expected) + { + var playlistPath = Path.Combine(_libraryRoot, "playlist.m3u"); + var content = new StringBuilder("#EXTM3U\n"); + foreach (var entry in entries) + { + content.Append("#EXTINF:1,Title\n").Append(entry).Append('\n'); + } + + File.WriteAllBytes( + playlistPath, + [.. encoding.GetPreamble(), .. encoding.GetBytes(content.ToString())]); + + using var stream = File.OpenRead(playlistPath); + var resolved = _sut.GetM3uItems(stream, playlistPath, [_libraryRoot]).ToList(); + + Assert.Equal(expected, resolved.Count); + } +} diff --git a/tests/Jellyfin.Providers.Tests/TV/SeriesMetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/TV/SeriesMetadataServiceTests.cs new file mode 100644 index 0000000000..3f6a7fb0b0 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/TV/SeriesMetadataServiceTests.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.TV; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.TV; + +/// <summary> +/// Covers reconciling missing episodes against the episodes the user has, which cannot be done on episode +/// numbers alone: aired, DVD and absolute orders number the same episodes differently, so the tail of the +/// longer order is the episodes the user owns rather than the ones they lack. +/// </summary> +public class SeriesMetadataServiceTests +{ + public SeriesMetadataServiceTests() + { + // An episode with a path asks the file system what kind of location it is. Only set when nothing else + // in the assembly has, since it is process-wide. + BaseItem.FileSystem ??= Mock.Of<IFileSystem>(); + } + + [Fact] + public void IsAlreadyPresent_VirtualEpisodeCarriesTheIdOfAPresentOne_ReturnsTrue() + { + // The Six Million Dollar Man S3: 21 files numbered in DVD order, 23 episodes in aired order, so + // aired 22 and 23 are the files stored as 20 and 21. + var present = SeriesMetadataService.GetPresentEpisodeIds( + [ + Physical(20, (MetadataProvider.Tvdb, "236715")), + Physical(21, (MetadataProvider.Tvdb, "236716")) + ]); + + Assert.True(SeriesMetadataService.IsAlreadyPresent(Virtual(22, (MetadataProvider.Tvdb, "236715")), present)); + Assert.True(SeriesMetadataService.IsAlreadyPresent(Virtual(23, (MetadataProvider.Tvdb, "236716")), present)); + } + + [Fact] + public void IsAlreadyPresent_GenuinelyMissingEpisode_ReturnsFalse() + { + // The aired-only two-parter the user really does not have keeps its missing episodes. + var present = SeriesMetadataService.GetPresentEpisodeIds( + [ + Physical(20, (MetadataProvider.Tvdb, "236715")), + Physical(21, (MetadataProvider.Tvdb, "236716")) + ]); + + Assert.False(SeriesMetadataService.IsAlreadyPresent(Virtual(15, (MetadataProvider.Tvdb, "4273533")), present)); + } + + [Fact] + public void GetPresentEpisodeIds_IdSharedBySeveralEpisodes_IsNotAnIdentity() + { + // A series' own IMDb id lands on every episode, and TMDb reports an unknown TVRage id as 0. Taking + // either for an episode identity would let one file delete every missing episode of the series. + var present = SeriesMetadataService.GetPresentEpisodeIds( + [ + Physical(1, (MetadataProvider.Imdb, "tt0836592"), (MetadataProvider.TvRage, "0"), (MetadataProvider.Tvdb, "1001")), + Physical(2, (MetadataProvider.Imdb, "tt0836592"), (MetadataProvider.TvRage, "0"), (MetadataProvider.Tvdb, "1002")) + ]); + + Assert.False(SeriesMetadataService.IsAlreadyPresent(Virtual(3, (MetadataProvider.Imdb, "tt0836592")), present)); + Assert.False(SeriesMetadataService.IsAlreadyPresent(Virtual(4, (MetadataProvider.TvRage, "0")), present)); + Assert.True(SeriesMetadataService.IsAlreadyPresent(Virtual(5, (MetadataProvider.Tvdb, "1001")), present)); + } + + [Fact] + public void GetPresentEpisodeIds_VirtualEpisode_DoesNotCountAsPresent() + { + // Two virtual episodes for the same episode must not cancel each other out into "present". + var present = SeriesMetadataService.GetPresentEpisodeIds( + [ + Virtual(22, (MetadataProvider.Tvdb, "236715")) + ]); + + Assert.Empty(present); + } + + [Fact] + public void GetPresentEpisodeIds_BlankStoredId_IsIgnored() + { + // SetProviderId refuses these, but a row written by an older version can still hold one. + var episode = Physical(1); + episode.ProviderIds["Tvdb"] = " "; + + Assert.Empty(SeriesMetadataService.GetPresentEpisodeIds([episode])); + } + + private static Episode Physical(int indexNumber, params (MetadataProvider Provider, string Id)[] providerIds) + => WithIds(new Episode { IndexNumber = indexNumber, Path = $"/media/show/Season 03/E{indexNumber}.mkv" }, providerIds); + + private static Episode Virtual(int indexNumber, params (MetadataProvider Provider, string Id)[] providerIds) + => WithIds(new Episode { IndexNumber = indexNumber, IsVirtualItem = true }, providerIds); + + private static Episode WithIds(Episode episode, IEnumerable<(MetadataProvider Provider, string Id)> providerIds) + { + foreach (var (provider, id) in providerIds) + { + episode.SetProviderId(provider, id); + } + + return episode; + } +} diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs index 8926a7b13c..8342621d97 100644 --- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs @@ -30,14 +30,25 @@ namespace Jellyfin.Providers.Tests.Tmdb } [Theory] - [InlineData("en", "en-US", "en-US")] - [InlineData("fr-CA", "fr-BE", "fr-CA")] - [InlineData("fr-CA", "fr", "fr-CA")] - [InlineData("de", "en-US", "de")] - [InlineData("", "en-US", "")] - public static void AdjustImageLanguage_Valid_Success(string imageLanguage, string requestLanguage, string? expected) + // A regional request is only answered with a region when the image contradicts it. + [InlineData("pt", "BR", "pt-BR", "pt-BR")] + [InlineData("pt", "PT", "pt-BR", "pt-PT")] + [InlineData("en", "GB", "en-US", "en-GB")] + [InlineData("en", "US", "en-US", "en-US")] + [InlineData("en", null, "en-US", "en-US")] + // TMDb files nearly every image under a region, which must not stop a plain request matching. + [InlineData("en", "US", "en", "en")] + [InlineData("pt", "PT", "pt", "pt")] + // An image in another language keeps its bare code, so the English fallback still resolves. + [InlineData("en", "US", "pt-BR", "en")] + [InlineData("de", "DE", "en-US", "de")] + // TMDb returns xx rather than an empty string for an image with no text. + [InlineData("xx", "US", "en-US", "")] + [InlineData("", "US", "en-US", "")] + [InlineData(null, null, "en-US", "")] + public static void GetImageLanguage_Valid_Success(string? imageLanguage, string? imageRegion, string? requestLanguage, string expected) { - Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage)); + Assert.Equal(expected, TmdbUtils.GetImageLanguage(imageLanguage, imageRegion, requestLanguage)); } [Theory] @@ -105,6 +116,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/Data/ItemTypeLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs new file mode 100644 index 0000000000..a60622902b --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs @@ -0,0 +1,26 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Data; + +public class ItemTypeLookupTests +{ + public static TheoryData<BaseItemKind> MappedKinds() + => new(new ItemTypeLookup().BaseItemKindNames.Keys); + + [Theory] + [MemberData(nameof(MappedKinds))] + public void BaseItemKindNames_Kind_NamesAnItemType(BaseItemKind kind) + { + var name = new ItemTypeLookup().BaseItemKindNames[kind]; + + var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType(name)).FirstOrDefault(t => t is not null); + + Assert.NotNull(type); + Assert.True(typeof(BaseItem).IsAssignableFrom(type), $"{name} is not an item type."); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs new file mode 100644 index 0000000000..153e1dc154 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Tests.Item; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Data; + +/// <summary> +/// Statistics taken on a freshly created database describe every table as a single row, and SQLite then plans +/// the user data and series queries of a filled library as full scans (#17886). +/// </summary> +public sealed class SqliteDatabaseStatisticsTests : SqliteDbTestFixture +{ + private readonly SqliteDatabaseProvider _provider; + + public SqliteDatabaseStatisticsTests() + { + _provider = new SqliteDatabaseProvider(ApplicationPaths, NullLogger<SqliteDatabaseProvider>.Instance) + { + DbContextFactory = CreateDbContextFactory() + }; + } + + [Fact] + public async Task RunScheduledOptimisation_EmptyLibrary_RecordsNoStatistics() + { + SeedFolders(3); + + await _provider.RunScheduledOptimisation(CancellationToken.None); + + Assert.Null(ReadAnalyzedItemCount()); + } + + [Fact] + public async Task RunScheduledOptimisation_LibraryWithItems_RecordsStatistics() + { + SeedFolders(1); + SeedEpisodes(4); + + await _provider.RunScheduledOptimisation(CancellationToken.None); + + Assert.Equal(CountItems(), ReadAnalyzedItemCount()); + } + + [Fact] + public async Task RefreshStatistics_NoStatistics_Analyzes() + { + SeedEpisodes(5); + + await _provider.RefreshStatistics(CancellationToken.None); + + Assert.Equal(CountItems(), ReadAnalyzedItemCount()); + } + + [Fact] + public async Task RefreshStatistics_LibraryChanged_Reanalyzes() + { + SeedEpisodes(10); + Analyze(); + SeedEpisodes(5); + + await _provider.RefreshStatistics(CancellationToken.None); + + Assert.Equal(CountItems(), ReadAnalyzedItemCount()); + } + + [Fact] + public async Task RefreshStatistics_EmptyLibrary_RecordsNoStatistics() + { + SeedFolders(2); + + await _provider.RefreshStatistics(CancellationToken.None); + + Assert.Null(ReadAnalyzedItemCount()); + } + + private void SeedFolders(int count) + { + using var context = CreateDbContext(); + context.BaseItems.AddRange(Enumerable.Range(0, count).Select(_ => new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = "MediaBrowser.Controller.Entities.Folder", + IsFolder = true + })); + context.SaveChanges(); + } + + private void SeedEpisodes(int count) + { + using var context = CreateDbContext(); + context.BaseItems.AddRange(Enumerable.Range(0, count).Select(_ => new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = "MediaBrowser.Controller.Entities.TV.Episode", + IsFolder = false + })); + context.SaveChanges(); + } + + private void Analyze() + { + using var context = CreateDbContext(); + context.Database.ExecuteSqlRaw("ANALYZE"); + } + + private long CountItems() + { + using var context = CreateDbContext(); + return context.BaseItems.LongCount(); + } + + private long? ReadAnalyzedItemCount() + { + using var context = CreateDbContext(); + var hasStatistics = context.Database + .SqlQueryRaw<long>("SELECT count(*) AS \"Value\" FROM sqlite_schema WHERE type = 'table' AND name = 'sqlite_stat1'") + .Single(); + if (hasStatistics == 0) + { + return null; + } + + return context.Database + .SqlQueryRaw<long?>("SELECT max(CAST(stat AS INTEGER)) AS \"Value\" FROM sqlite_stat1 WHERE tbl = 'BaseItems'") + .Single(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/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/Images/CollectionFolderImageProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs new file mode 100644 index 0000000000..b88296a06a --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.Images; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Images; + +/// <summary> +/// A music library is collaged from its artists' backdrops. Artists are by-name items with no +/// library of their own, so they have to be asked for through the by-name listing, which reaches +/// them through the tracks that credit them; an item query for them ignores the library scope and +/// hands back the artists of every music library. +/// </summary> +public sealed class CollectionFolderImageProviderTests +{ + [Fact] + public void GetItemsWithImages_MusicLibrary_AsksForTheArtistsOfThatLibraryOnly() + { + var view = new CollectionFolder { Id = Guid.NewGuid(), CollectionType = CollectionType.music }; + var artist = new MusicArtist { Id = Guid.NewGuid(), Name = "Artist" }; + + InternalItemsQuery? query = null; + var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(l => l.GetAllArtists(It.IsAny<InternalItemsQuery>())) + .Callback<InternalItemsQuery>(q => query = q) + .Returns(new QueryResult<(BaseItem Item, ItemCounts ItemCounts)>([(artist, new ItemCounts())])); + + var items = CreateProvider(libraryManager.Object).GetItems(view); + + Assert.Equal([artist], items); + Assert.NotNull(query); + Assert.Equal([view.Id], query.AncestorIds); + Assert.Equal([ImageType.Primary], query.ImageTypes); + Assert.Equal(8, query.Limit); + } + + private static TestableCollectionFolderImageProvider CreateProvider(ILibraryManager libraryManager) + { + return new TestableCollectionFolderImageProvider( + Mock.Of<IFileSystem>(), + Mock.Of<IProviderManager>(), + Mock.Of<IApplicationPaths>(), + Mock.Of<IImageProcessor>(), + libraryManager); + } + + private sealed class TestableCollectionFolderImageProvider : CollectionFolderImageProvider + { + public TestableCollectionFolderImageProvider( + IFileSystem fileSystem, + IProviderManager providerManager, + IApplicationPaths applicationPaths, + IImageProcessor imageProcessor, + ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) + { + } + + public IReadOnlyList<BaseItem> GetItems(BaseItem item) => GetItemsWithImages(item); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs new file mode 100644 index 0000000000..0c1d453293 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Moq; +using Xunit; +using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class ArtistNameLookupTests : SqliteDbTestFixture +{ + private readonly CommandRecorder _recorder; + + public ArtistNameLookupTests() + : this(new CommandRecorder()) + { + } + + private ArtistNameLookupTests(CommandRecorder recorder) + : base(recorder) + { + _recorder = recorder; + } + + [Theory] + [InlineData("Björk", "bjork")] + [InlineData("AC/DC", "ac dc")] + [InlineData("An Artist", " AN ARTIST ")] + [InlineData("Artist", "ARTIST")] + public void GetArtist_UsesSameNormalizedNameAsFindArtists(string storedName, string requestedName) + { + var lookup = new ItemTypeLookup(); + var artistId = Guid.NewGuid(); + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange( + new BaseItemEntity + { + Id = artistId, + Name = storedName, + CleanName = storedName.GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }, + new BaseItemEntity + { + Id = Guid.NewGuid(), + Name = storedName, + CleanName = storedName.GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.Person] + }); + context.SaveChanges(); + } + + var manager = CreateLibraryManager(lookup); + _recorder.Commands.Clear(); + + Assert.Equal(artistId, manager.GetArtist(requestedName).Id); + var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("\"CleanName\" =", StringComparison.Ordinal)); + Assert.Contains(Explain(query), line => line.Contains("IX_BaseItems_Type_CleanName (Type=? AND CleanName=?)", StringComparison.Ordinal)); + Assert.Equal(artistId, Assert.Single(manager.GetArtists([requestedName])[requestedName]).Id); + } + + [Fact] + public void GetArtist_PrefersFilesystemArtistWhenNormalizedNamesMatch() + { + var lookup = new ItemTypeLookup(); + var parentId = Guid.NewGuid(); + var artistId = Guid.NewGuid(); + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange( + new BaseItemEntity + { + Id = parentId, + Name = "Music", + Type = lookup.BaseItemKindNames[BaseItemKind.Folder] + }, + new BaseItemEntity + { + Id = Guid.NewGuid(), + Name = "Bjork", + CleanName = "Bjork".GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }, + new BaseItemEntity + { + Id = artistId, + ParentId = parentId, + Name = "Björk", + CleanName = "Björk".GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }); + context.SaveChanges(); + } + + Assert.Equal(artistId, CreateLibraryManager(lookup).GetArtist("Bjork").Id); + } + + private ServerLibraryManager CreateLibraryManager(ItemTypeLookup lookup) + { + var repository = CreateBaseItemRepository(lookup); + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>(); + configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + fixture.Inject<IItemRepository>(repository); + fixture.Inject<ILinkedChildrenService>(new LinkedChildrenService(CreateDbContextFactory(), lookup, repository)); + return fixture.Create<ServerLibraryManager>(); + } + + private string[] Explain(RecordedCommand query) + { + using var context = CreateDbContext(); + using var command = context.Database.GetDbConnection().CreateCommand(); +#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters. + command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql; +#pragma warning restore CA2100 + foreach (var value in query.Parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = value.Name; + parameter.Value = value.Value; + command.Parameters.Add(parameter); + } + + using var reader = command.ExecuteReader(); + var plan = new List<string>(); + while (reader.Read()) + { + plan.Add(reader.GetString(3)); + } + + return plan.ToArray(); + } + + private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters); + + private sealed class CommandRecorder : DbCommandInterceptor + { + public List<RecordedCommand> Commands { get; } = []; + + public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result) + { + Record(command); + return result; + } + + public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result) + { + Record(command); + return result; + } + + private void Record(DbCommand command) => Commands.Add(new RecordedCommand( + command.CommandText, + command.Parameters.Cast<DbParameter>().Select(p => (p.ParameterName, p.Value)).ToArray())); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/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/BaseItemRepositoryAllowedTagsByNameTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs new file mode 100644 index 0000000000..3c8bd9fd50 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// A user restricted to a set of allowed tags still has to see the by-name items - genres, studios, +/// artists - of the media those tags let through. The by-name item carries none of the media's tags, +/// so the allowed-tags filter would otherwise hide every one of them. +/// </summary> +public sealed class BaseItemRepositoryAllowedTagsByNameTests : SqliteDbTestFixture +{ + private const string AllowedTag = "kids"; + + private readonly BaseItemRepository _repository; + private readonly ItemTypeLookup _itemTypeLookup; + + public BaseItemRepositoryAllowedTagsByNameTests() + { + _itemTypeLookup = new ItemTypeLookup(); + _repository = CreateBaseItemRepository(_itemTypeLookup); + Seed(); + } + + [Fact] + public void GetGenres_WithAllowedTags_ReturnsGenresOfAllowedItems() + { + var result = _repository.GetGenres(CreateQuery(AllowedTag)); + + Assert.Equal(["Comedy"], Names(result)); + } + + [Fact] + public void GetStudios_WithAllowedTags_ReturnsStudiosOfAllowedItems() + { + var result = _repository.GetStudios(CreateQuery(AllowedTag)); + + Assert.Equal(["Pixar"], Names(result)); + } + + [Fact] + public void GetAllArtists_WithAllowedTags_ReturnsArtistsOfAllowedItems() + { + var result = _repository.GetAllArtists(CreateQuery(AllowedTag)); + + Assert.Equal(["Raffi"], Names(result)); + } + + [Fact] + public void GetGenres_WithoutAllowedTags_ReturnsEveryGenre() + { + var result = _repository.GetGenres(CreateQuery()); + + Assert.Equal(["Comedy", "Horror", "Jazz"], Names(result)); + } + + private static string[] Names(MediaBrowser.Model.Querying.QueryResult<(BaseItem Item, MediaBrowser.Model.Dto.ItemCounts? ItemCounts)> result) + => result.Items.Select(i => i.Item.Name!).Order(StringComparer.Ordinal).ToArray(); + + private static InternalItemsQuery CreateQuery(params string[] allowedTags) + { + var user = new User("restricted", "auth", "reset"); + if (allowedTags.Length > 0) + { + user.SetPreference(PreferenceKind.AllowedTags, allowedTags); + } + + return new InternalItemsQuery(user); + } + + private void Seed() + { + using var context = CreateDbContext(); + + // Tagged media, plus the by-name items describing it. + var movie = CreateItem(BaseItemKind.Movie, "Allowed Movie", mediaType: "Video"); + var song = CreateItem(BaseItemKind.Audio, "Allowed Song", mediaType: "Audio"); + var genre = CreateItem(BaseItemKind.Genre, "Comedy"); + var studio = CreateItem(BaseItemKind.Studio, "Pixar"); + var artist = CreateItem(BaseItemKind.MusicArtist, "Raffi"); + + // Media the allow list keeps out, plus the by-name items only it is described by. + var blockedMovie = CreateItem(BaseItemKind.Movie, "Untagged Movie", mediaType: "Video"); + var blockedGenre = CreateItem(BaseItemKind.Genre, "Horror"); + + // A genre written on nothing but a by-name item: that item is no more visible than the + // media behind it, so the genre must not leak into a restricted user's list. + var artistOnlyGenre = CreateItem(BaseItemKind.Genre, "Jazz"); + var blockedArtist = CreateItem(BaseItemKind.MusicArtist, "Mingus"); + + var tag = CreateItemValue(ItemValueType.Tags, AllowedTag); + var comedy = CreateItemValue(ItemValueType.Genre, "Comedy"); + var horror = CreateItemValue(ItemValueType.Genre, "Horror"); + var jazz = CreateItemValue(ItemValueType.Genre, "Jazz"); + var pixar = CreateItemValue(ItemValueType.Studios, "Pixar"); + var raffi = CreateItemValue(ItemValueType.Artist, "Raffi"); + + context.BaseItems.AddRange(movie, song, genre, studio, artist, blockedMovie, blockedGenre, artistOnlyGenre, blockedArtist); + context.ItemValues.AddRange(tag, comedy, horror, jazz, pixar, raffi); + context.ItemValuesMap.AddRange( + CreateMap(movie, tag), + CreateMap(movie, comedy), + CreateMap(movie, pixar), + CreateMap(song, tag), + CreateMap(song, raffi), + CreateMap(blockedMovie, horror), + CreateMap(blockedArtist, jazz)); + context.SaveChanges(); + } + + private BaseItemEntity CreateItem(BaseItemKind kind, string name, string? mediaType = null) + { + var id = Guid.NewGuid(); + + return new BaseItemEntity + { + Id = id, + Type = _itemTypeLookup.BaseItemKindNames[kind], + Name = name, + CleanName = name.ToLowerInvariant(), + PresentationUniqueKey = id.ToString("N"), + MediaType = mediaType, + IsMovie = kind == BaseItemKind.Movie, + IsFolder = false, + IsVirtualItem = false + }; + } + + private static ItemValue CreateItemValue(ItemValueType type, string value) + { + return new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = type, + Value = value, + CleanValue = value.ToLowerInvariant() + }; + } + + private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue) + { + return new ItemValueMap + { + ItemId = item.Id, + ItemValueId = itemValue.ItemValueId, + Item = item, + ItemValue = itemValue + }; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs new file mode 100644 index 0000000000..1f8d9820d6 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Artists are by-name items: they live outside any library and carry no TopParentId, so a plain +/// item query for them is exempt from the library filter and spans every music library. Only the +/// by-name listings, which reach the artist through the tracks that credit it, can be scoped to +/// one library. +/// </summary> +public sealed class BaseItemRepositoryArtistLibraryScopeTests : SqliteDbTestFixture +{ + private static readonly Guid _firstLibrary = Guid.Parse("11111111-0000-0000-0000-000000000001"); + private static readonly Guid _secondLibrary = Guid.Parse("22222222-0000-0000-0000-000000000001"); + + private readonly BaseItemRepository _repository; + private readonly ItemTypeLookup _itemTypeLookup; + + public BaseItemRepositoryArtistLibraryScopeTests() + { + _itemTypeLookup = new ItemTypeLookup(); + _repository = CreateBaseItemRepository(_itemTypeLookup); + + Seed("First Artist", "first artist", _firstLibrary, hasImage: true); + Seed("Second Artist", "second artist", _secondLibrary, hasImage: true); + } + + [Fact] + public void GetItemList_MusicArtistsScopedToOneLibrary_ReturnsEveryLibrarysArtists() + { + // The shape the library cover image used to be built from. By-name types are exempt from + // the TopParentId filter, so the scope is silently dropped. + var result = _repository.GetItemList(new InternalItemsQuery + { + DtoOptions = new DtoOptions(false), + IncludeItemTypes = [BaseItemKind.MusicArtist], + TopParentIds = [_firstLibrary] + }); + + Assert.Equal(["First Artist", "Second Artist"], result.Select(i => i.Name).OrderBy(n => n)); + } + + [Fact] + public void GetAllArtists_ScopedToOneLibrary_ReturnsOnlyThatLibrarysArtists() + { + var result = _repository.GetAllArtists(new InternalItemsQuery + { + DtoOptions = new DtoOptions(false), + TopParentIds = [_firstLibrary] + }); + + var (artist, _) = Assert.Single(result.Items); + Assert.Equal("First Artist", artist.Name); + } + + [Fact] + public void GetAllArtists_ImageTypes_DropsArtistsWithoutThatImage() + { + // The collage has nothing to draw with an artist that has no image, so the listing has to + // honour the image filter the caller asked for. + Seed("Third Artist", "third artist", _firstLibrary, hasImage: false); + + var result = _repository.GetAllArtists(new InternalItemsQuery + { + DtoOptions = new DtoOptions(false), + ImageTypes = [ImageType.Primary], + TopParentIds = [_firstLibrary] + }); + + var (artist, _) = Assert.Single(result.Items); + Assert.Equal("First Artist", artist.Name); + } + + /// <summary> + /// Seeds one by-name artist row and a track in the given library crediting it. + /// </summary> + /// <param name="name">The artist name.</param> + /// <param name="cleanName">The cleaned artist name, which is what links the two rows.</param> + /// <param name="topParentId">The library the track belongs to.</param> + /// <param name="hasImage">Whether the artist row carries a primary image.</param> + private void Seed(string name, string cleanName, Guid topParentId, bool hasImage) + { + using var ctx = CreateDbContext(); + + var artistId = Guid.NewGuid(); + var artist = new BaseItemEntity + { + Id = artistId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist], + Name = name, + CleanName = cleanName, + PresentationUniqueKey = artistId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }; + + if (hasImage) + { + artist.Images = + [ + new BaseItemImageInfo + { + Id = Guid.NewGuid(), + ItemId = artistId, + Item = artist, + ImageType = ImageInfoImageType.Primary, + Path = $"/metadata/artists/{cleanName}/folder.jpg" + } + ]; + } + + ctx.BaseItems.Add(artist); + + var trackId = Guid.NewGuid(); + var track = new BaseItemEntity + { + Id = trackId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio], + Name = $"{name} - Track", + CleanName = $"{cleanName} - track", + PresentationUniqueKey = trackId.ToString("N"), + MediaType = "Audio", + TopParentId = topParentId, + IsFolder = false, + IsVirtualItem = false + }; + ctx.BaseItems.Add(track); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.AlbumArtist, + Value = name, + CleanValue = cleanName + }; + + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = trackId, + ItemValueId = itemValue.ItemValueId, + Item = track, + ItemValue = itemValue + }); + + ctx.SaveChanges(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/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/BaseItemRepositoryInheritedTagTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs new file mode 100644 index 0000000000..d995293923 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs @@ -0,0 +1,175 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers the inherited-tag filters a user's blocked and allowed tags turn into. A tag reaches an item +/// four ways - on the item, on its series, on an ancestor and on its library - and each of them has to +/// keep answering the same after the ancestor check moved off a per-row correlated subquery. +/// </summary> +public sealed class BaseItemRepositoryInheritedTagTests : SqliteDbTestFixture +{ + private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; + private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series"; + private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + + private const string Tag = "Adult"; + + private readonly BaseItemRepository _repository; + + private readonly Guid _library = Guid.NewGuid(); + private readonly Guid _otherLibrary = Guid.NewGuid(); + + // Tagged on the item itself. + private readonly Guid _taggedSeries = Guid.NewGuid(); + + // Inherits the tag from the series it belongs to, without an ancestor row for it. + private readonly Guid _episodeOfTaggedSeries = Guid.NewGuid(); + + // Inherits the tag from a season in the middle of its ancestor chain. + private readonly Guid _taggedSeason = Guid.NewGuid(); + private readonly Guid _episodeUnderTaggedSeason = Guid.NewGuid(); + + // Inherits the tag from the library above it. + private readonly Guid _taggedLibrarySeries = Guid.NewGuid(); + + // Carries the tag nowhere, the control the assertions are read against. + private readonly Guid _untaggedSeries = Guid.NewGuid(); + private readonly Guid _untaggedEpisode = Guid.NewGuid(); + + public BaseItemRepositoryInheritedTagTests() + { + using (var ctx = CreateDbContext()) + { + Seed(ctx); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void ExcludeInheritedTags_DropsEveryItemTheTagReaches() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { ExcludeInheritedTags = [Tag] }).ToHashSet(); + + // The blocked library carries the tag itself, so it goes with everything under it. + Assert.Equal( + new[] { _library, _untaggedSeries, _untaggedEpisode }.Order(), + ids.Order()); + } + + [Fact] + public void IncludeInheritedTags_KeepsExactlyTheItemsTheTagReaches() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { IncludeInheritedTags = [Tag] }).ToHashSet(); + + Assert.Equal( + new[] { _otherLibrary, _taggedSeries, _episodeOfTaggedSeries, _taggedSeason, _episodeUnderTaggedSeason, _taggedLibrarySeries }.Order(), + ids.Order()); + } + + [Fact] + public void ExcludeInheritedTags_DropsAnItemReachedOnlyThroughAnAncestor() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Episode], + ExcludeInheritedTags = [Tag] + }); + + Assert.Equal([_untaggedEpisode], ids); + } + + [Fact] + public void ExcludeInheritedTags_WithAnUnusedTag_KeepsEverything() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { ExcludeInheritedTags = ["Unused"] }); + + Assert.Equal(9, ids.Count); + } + + private void Seed(JellyfinDbContext context) + { + AddItem(context, _library, FolderType, "Shows", true); + AddItem(context, _otherLibrary, FolderType, "Blocked library", true); + AddItem(context, _taggedSeries, SeriesType, "Tagged series", true); + AddItem(context, _episodeOfTaggedSeries, EpisodeType, "Episode of tagged series", false, _taggedSeries); + AddItem(context, _taggedSeason, SeasonType, "Tagged season", true); + AddItem(context, _episodeUnderTaggedSeason, EpisodeType, "Episode under tagged season", false); + AddItem(context, _taggedLibrarySeries, SeriesType, "Series in blocked library", true); + AddItem(context, _untaggedSeries, SeriesType, "Untagged series", true); + AddItem(context, _untaggedEpisode, EpisodeType, "Untagged episode", false, _untaggedSeries); + + // AncestorIds is a closure: production writes one row per ancestor, not just the parent. The + // episode of the tagged series deliberately has none, so the series branch is what has to catch it. + AddAncestors(context, _taggedSeries, _library); + AddAncestors(context, _taggedSeason, _library); + AddAncestors(context, _episodeUnderTaggedSeason, _taggedSeason, _library); + AddAncestors(context, _taggedLibrarySeries, _otherLibrary); + AddAncestors(context, _untaggedSeries, _library); + AddAncestors(context, _untaggedEpisode, _untaggedSeries, _library); + + Tagged(context, _taggedSeries, _taggedSeason, _otherLibrary); + + context.SaveChanges(); + } + + private void AddItem(JellyfinDbContext context, Guid id, string type, string name, bool isFolder, Guid? seriesId = null) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = type, + Name = name, + IsFolder = isFolder, + SeriesId = seriesId + }); + } + + private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds) + { + foreach (var ancestorId in ancestorIds) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = ancestorId, + Item = null!, + ParentItem = null! + }); + } + } + + private void Tagged(JellyfinDbContext context, params Guid[] itemIds) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = Tag, + CleanValue = Tag.GetCleanValue() + }; + + context.ItemValues.Add(itemValue); + foreach (var itemId in itemIds) + { + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = itemId, + ItemValueId = itemValue.ItemValueId, + Item = null!, + ItemValue = null! + }); + } + } +} 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/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs index 82614c3156..fea6ccb7a1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs @@ -2,12 +2,16 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; 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 MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging.Abstractions; @@ -21,12 +25,15 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture private readonly ItemPersistenceService _service; private readonly ILibraryManager? _previousLibraryManager; private readonly IServerConfigurationManager? _previousConfigurationManager; + private readonly IRecordingsManager? _previousRecordingsManager; + private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111"); public ItemPersistenceOwnedRowTests() { // 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>())) @@ -37,6 +44,9 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); BaseItem.ConfigurationManager = configurationManager.Object; + // Video.SourceType consults this before it can produce user data keys. + Video.RecordingsManager = new Mock<IRecordingsManager>().Object; + _service = new ItemPersistenceService( CreateDbContextFactory(), new Mock<IServerApplicationHost>().Object, @@ -47,6 +57,7 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture { BaseItem.LibraryManager = _previousLibraryManager!; BaseItem.ConfigurationManager = _previousConfigurationManager!; + Video.RecordingsManager = _previousRecordingsManager!; base.Dispose(disposing); } @@ -102,6 +113,99 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture Assert.Equal("777", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(fresh))).ProviderValue); } + [Fact] + public async Task ReattachUserData_DetachedRowsFromDifferentEras_CollapsesToMostRecentPlay() + { + var movie = CreateMovie(Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd")); + var keys = movie.GetUserDataKeys(); + SeedUserDataItem(movie); + + using (var ctx = CreateDbContext()) + { + // The guid-keyed row was detached by an older deletion than the provider-keyed ones. + ctx.UserData.AddRange( + CreateDetachedRow(keys[^1], new DateTime(2021, 12, 31, 0, 0, 0, DateTimeKind.Utc), playCount: 7, positionTicks: 490), + CreateDetachedRow(keys[0], new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true), + CreateDetachedRow(keys[1], new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true)); + await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken); + + using (var ctx = CreateDbContext()) + { + var rows = ctx.UserData.Where(e => e.ItemId.Equals(movie.Id)).ToList(); + + Assert.Equal(keys.Count, rows.Count); + Assert.Equal(keys.OrderBy(e => e, StringComparer.Ordinal), rows.Select(e => e.CustomDataKey).OrderBy(e => e, StringComparer.Ordinal)); + Assert.All(rows, row => + { + Assert.True(row.Played); + Assert.Equal(0, row.PlaybackPositionTicks); + Assert.Equal(9, row.PlayCount); + Assert.Null(row.RetentionDate); + }); + + Assert.Empty(ctx.UserData.Where(e => e.ItemId.Equals(BaseItemRepository.PlaceholderId))); + } + } + + [Fact] + public async Task ReattachUserData_ExistingRowUnderUnreportedKey_IsKeptInAgreement() + { + var movie = CreateMovie(Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff")); + var keys = movie.GetUserDataKeys(); + const string UnreportedKey = "tvdb-key-missing-mid-refresh"; + SeedUserDataItem(movie); + + using (var ctx = CreateDbContext()) + { + ctx.UserData.AddRange( + CreateRow(movie.Id, UnreportedKey, new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 1, positionTicks: 123), + CreateDetachedRow(keys[0], new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 2, positionTicks: 0, played: true)); + await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken); + + using (var ctx = CreateDbContext()) + { + var rows = ctx.UserData.Where(e => e.ItemId.Equals(movie.Id)).ToList(); + + Assert.Equal( + keys.Append(UnreportedKey).OrderBy(e => e, StringComparer.Ordinal), + rows.Select(e => e.CustomDataKey).OrderBy(e => e, StringComparer.Ordinal)); + Assert.All(rows, row => + { + Assert.True(row.Played); + Assert.Equal(2, row.PlayCount); + Assert.Equal(0, row.PlaybackPositionTicks); + }); + } + } + + [Fact] + public async Task ReattachUserData_NoDetachedRows_LeavesExistingRowsAlone() + { + var movie = CreateMovie(Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")); + var keys = movie.GetUserDataKeys(); + SeedUserDataItem(movie); + + using (var ctx = CreateDbContext()) + { + ctx.UserData.Add(CreateRow(movie.Id, keys[0], new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 1, positionTicks: 123)); + await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken); + + using (var ctx = CreateDbContext()) + { + var row = Assert.Single(ctx.UserData.Where(e => e.ItemId.Equals(movie.Id))); + Assert.Equal(123, row.PlaybackPositionTicks); + } + } + private static Book CreateBook(Guid id, Dictionary<string, string> providerIds, MetadataField[] lockedFields) { var book = new Book @@ -115,4 +219,59 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0); return book; } + + private static Movie CreateMovie(Guid id) + { + return new Movie + { + Id = id, + Name = "Black Widow", + ProviderIds = new Dictionary<string, string> + { + ["Tmdb"] = "497698", + ["Imdb"] = "tt3480822" + } + }; + } + + private void SeedUserDataItem(BaseItem item) + { + using var ctx = CreateDbContext(); + if (!ctx.Users.Any(e => e.Id.Equals(_userId))) + { + ctx.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId }); + } + + if (!ctx.BaseItems.Any(e => e.Id.Equals(BaseItemRepository.PlaceholderId))) + { + ctx.BaseItems.Add(new BaseItemEntity { Id = BaseItemRepository.PlaceholderId, Type = typeof(Folder).FullName! }); + } + + ctx.BaseItems.Add(new BaseItemEntity { Id = item.Id, Type = item.GetType().FullName! }); + ctx.SaveChanges(); + } + + private UserData CreateDetachedRow(string key, DateTime lastPlayed, int playCount, long positionTicks, bool played = false) + { + var row = CreateRow(BaseItemRepository.PlaceholderId, key, lastPlayed, playCount, positionTicks, played); + row.RetentionDate = new DateTime(2025, 6, 22, 0, 0, 0, DateTimeKind.Utc); + + return row; + } + + private UserData CreateRow(Guid itemId, string key, DateTime lastPlayed, int playCount, long positionTicks, bool played = false) + { + return new UserData + { + ItemId = itemId, + Item = null, + UserId = _userId, + User = null, + CustomDataKey = key, + LastPlayedDate = lastPlayed, + PlayCount = playCount, + PlaybackPositionTicks = positionTicks, + Played = played + }; + } } 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/ItemValuesCleanupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemValuesCleanupTests.cs new file mode 100644 index 0000000000..cbfab403dc --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemValuesCleanupTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.Library; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class ItemValuesCleanupTests : SqliteDbTestFixture +{ + private readonly ItemPersistenceService _service; + + public ItemValuesCleanupTests() + { + _service = new ItemPersistenceService( + CreateDbContextFactory(), + Mock.Of<IServerApplicationHost>(), + NullLogger<ItemPersistenceService>.Instance); + } + + [Fact] + public void DeleteItem_LastReference_RemovesNewAndExistingOrphans() + { + var deleted = CreateItem(); + var survivor = CreateItem(); + var referenced = CreateValue("Referenced"); + using (var context = CreateDbContext()) + { + context.ItemValuesMap.AddRange( + Map(deleted, CreateValue("Last reference")), + Map(survivor, referenced)); + context.ItemValues.Add(CreateValue("Already orphaned")); + context.SaveChanges(); + } + + _service.DeleteItem([deleted.Id]); + + using var after = CreateDbContext(); + Assert.False(after.BaseItems.Any(e => e.Id.Equals(deleted.Id))); + Assert.Equal(referenced.ItemValueId, Assert.Single(after.ItemValues).ItemValueId); + Assert.Equal(survivor.Id, Assert.Single(after.ItemValuesMap).ItemId); + } + + [Fact] + public void DeleteItem_BatchWithDescendantAndOwnedExtra_CleansAllRemovedReferences() + { + var parent = CreateItem(isFolder: true); + var child = CreateItem(); + child.ParentId = parent.Id; + var extra = CreateItem(); + extra.OwnerId = child.Id; + var otherDeleted = CreateItem(); + var survivor = CreateItem(); + var batchShared = CreateValue("Shared inside deletion batch"); + var survivingShared = CreateValue("Shared with surviving item"); + using (var context = CreateDbContext()) + { + context.ItemValuesMap.AddRange( + Map(parent, CreateValue("Parent value")), + Map(child, batchShared), + Map(otherDeleted, batchShared), + Map(extra, CreateValue("Extra value")), + Map(child, survivingShared), + Map(survivor, survivingShared)); + context.AncestorIds.Add(new AncestorId + { + ItemId = child.Id, + Item = child, + ParentItemId = parent.Id, + ParentItem = parent + }); + context.SaveChanges(); + } + + _service.DeleteItem([parent.Id, otherDeleted.Id]); + + using var after = CreateDbContext(); + Assert.Equal(survivingShared.ItemValueId, Assert.Single(after.ItemValues).ItemValueId); + Assert.Equal(survivor.Id, Assert.Single(after.ItemValuesMap).ItemId); + Assert.Equal(survivor.Id, Assert.Single(after.BaseItems.Where(e => !e.Id.Equals(BaseItemRepository.PlaceholderId))).Id); + Assert.Empty(after.AncestorIds); + } + + [Fact] + public void DeleteItem_ChildWithoutAncestorRows_CleansValueOrphanedByCascade() + { + var parent = CreateItem(isFolder: true); + var child = CreateItem(); + child.ParentId = parent.Id; + using (var context = CreateDbContext()) + { + context.BaseItems.Add(parent); + context.ItemValuesMap.Add(Map(child, CreateValue("Cascaded child value"))); + context.SaveChanges(); + } + + _service.DeleteItem([parent.Id]); + + using var after = CreateDbContext(); + Assert.False(after.BaseItems.Any(e => e.Id.Equals(child.Id))); + Assert.Empty(after.ItemValuesMap); + Assert.Empty(after.ItemValues); + } + + [Fact] + public void DeleteItem_CleanupFails_RollsBackItemAndMapDeletion() + { + var item = CreateItem(); + var value = CreateValue("Last reference"); + using (var context = CreateDbContext()) + { + context.ItemValuesMap.Add(Map(item, value)); + context.ItemValues.Add(CreateValue("Already orphaned")); + context.SaveChanges(); + context.Database.ExecuteSqlRaw(""" + CREATE TRIGGER FailItemValuesCleanup BEFORE DELETE ON ItemValues + BEGIN + SELECT RAISE(ABORT, 'injected orphan cleanup failure'); + END; + """); + } + + var exception = Assert.Throws<SqliteException>(() => _service.DeleteItem([item.Id])); + + Assert.Contains("injected orphan cleanup failure", exception.Message, StringComparison.Ordinal); + using var after = CreateDbContext(); + Assert.True(after.BaseItems.Any(e => e.Id.Equals(item.Id))); + Assert.Equal(value.ItemValueId, Assert.Single(after.ItemValuesMap).ItemValueId); + Assert.Equal(2, after.ItemValues.Count()); + } + + [Fact] + public async Task PostScanRun_NoDeadItems_RemovesUnrelatedOrphansAndPreservesReferences() + { + var item = CreateItem(); + var referenced = CreateValue("Referenced"); + using (var context = CreateDbContext()) + { + context.ItemValuesMap.Add(Map(item, referenced)); + context.ItemValues.Add(CreateValue("Unrelated orphan")); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var library = new Mock<ILibraryManager>(MockBehavior.Strict); + library.Setup(e => e.GetItemIds(It.Is<InternalItemsQuery>(query => query.HasDeadParentId == true))) + .Returns(Array.Empty<Guid>()); + library.Setup(e => e.GetItemList(It.IsAny<InternalItemsQuery>())).Returns(Array.Empty<BaseItem>()); + var task = new CleanDatabaseScheduledTask( + library.Object, + NullLogger<CleanDatabaseScheduledTask>.Instance, + CreateDbContextFactory(), + Mock.Of<IPathManager>(MockBehavior.Strict)); + + await task.Run(Mock.Of<IProgress<double>>(), TestContext.Current.CancellationToken); + + using var after = CreateDbContext(); + Assert.Equal(referenced.ItemValueId, Assert.Single(after.ItemValues).ItemValueId); + Assert.Equal(item.Id, Assert.Single(after.ItemValuesMap).ItemId); + Assert.True(after.BaseItems.Any(e => e.Id.Equals(item.Id))); + } + + private static BaseItemEntity CreateItem(bool isFolder = false) => new() + { + Id = Guid.NewGuid(), + Type = isFolder ? typeof(Folder).FullName! : typeof(Book).FullName!, + IsFolder = isFolder + }; + + private static ItemValue CreateValue(string value) => new() + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = value, + CleanValue = value.ToLowerInvariant() + }; + + private static ItemValueMap Map(BaseItemEntity item, ItemValue value) => new() + { + ItemId = item.Id, + Item = item, + ItemValueId = value.ItemValueId, + ItemValue = value + }; +} 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/PeopleRepositoryMustHaveItemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs new file mode 100644 index 0000000000..f143ce1e20 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs @@ -0,0 +1,85 @@ +using System; +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 Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers <see cref="InternalPeopleQuery.MustHaveItem"/>. A caller that hands back by-name items can +/// only return the people that have one, so the count it reports has to leave out the rest rather than +/// describe a larger set than it can page through. +/// </summary> +public sealed class PeopleRepositoryMustHaveItemTests : SqliteDbTestFixture +{ + private readonly PeopleRepository _people; + private readonly Guid _movie = Guid.NewGuid(); + + public PeopleRepositoryMustHaveItemTests() + { + var lookup = new ItemTypeLookup(); + using (var context = CreateDbContext()) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = _movie, + Name = "Movie", + Type = lookup.BaseItemKindNames[BaseItemKind.Movie] + }); + + // Only one of the two credits has a by-name item behind it. + context.BaseItems.Add(new BaseItemEntity + { + Id = Guid.NewGuid(), + Name = "With Item", + Type = lookup.BaseItemKindNames[BaseItemKind.Person] + }); + + context.SaveChanges(); + } + + _people = new PeopleRepository(CreateDbContextFactory(), lookup, Mock.Of<IItemQueryHelpers>()); + _people.UpdatePeople(_movie, [ + new PersonInfo { Name = "With Item", Type = PersonKind.Actor }, + new PersonInfo { Name = "Without Item", Type = PersonKind.Actor } + ]); + } + + [Fact] + public void WithoutMustHaveItem_ReturnsAndCountsEveryCredit() + { + var result = _people.GetPeople(new InternalPeopleQuery()); + + Assert.Equal(2, result.TotalRecordCount); + Assert.Equal(2, result.Items.Count); + } + + [Fact] + public void MustHaveItem_DropsTheCreditWithoutAnItem() + { + var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true }); + + Assert.Equal("With Item", Assert.Single(result.Items).Name); + } + + [Fact] + public void MustHaveItem_CountsOnlyWhatItCanReturn() + { + var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true }); + + Assert.Equal(1, result.TotalRecordCount); + } + + [Fact] + public void MustHaveItem_CountAgreesWithThePageWhenLimited() + { + var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true, Limit = 10 }); + + Assert.Equal(result.Items.Count, result.TotalRecordCount); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryNameRangeTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryNameRangeTests.cs new file mode 100644 index 0000000000..754b388ecf --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryNameRangeTests.cs @@ -0,0 +1,53 @@ +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 MediaBrowser.Controller.Persistence; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// The letter picker on the person list filters with <c>NameStartsWithOrGreater</c> and <c>NameLessThan</c>. +/// Names keep the case a metadata provider wrote them in, so the range has to ignore case the same way +/// the list is sorted, or a capitalised name lands before every lowercase bound. +/// </summary> +public sealed class PeopleRepositoryNameRangeTests : SqliteDbTestFixture +{ + private readonly PeopleRepository _repository; + + public PeopleRepositoryNameRangeTests() + { + using (var context = CreateDbContext()) + { + foreach (var name in new[] { "50 Cent", "alpha centauri", "Brad Pitt", "bob dylan", "Zoe Saldana" }) + { + context.Peoples.Add(new People { Id = Guid.NewGuid(), Name = name, PersonType = "Actor" }); + } + + context.SaveChanges(); + } + + _repository = new PeopleRepository(CreateDbContextFactory(), new ItemTypeLookup(), new Mock<IItemQueryHelpers>().Object); + } + + [Theory] + [InlineData(null, "A", new[] { "50 Cent" })] + [InlineData(null, "C", new[] { "50 Cent", "alpha centauri", "bob dylan", "Brad Pitt" })] + [InlineData("B", "C", new[] { "bob dylan", "Brad Pitt" })] + [InlineData("Y", null, new[] { "Zoe Saldana" })] + public void GetPeople_NameRange_IgnoresCase(string? nameStartsWithOrGreater, string? nameLessThan, string[] expected) + { + var result = _repository.GetPeople(new InternalPeopleQuery + { + NameStartsWithOrGreater = nameStartsWithOrGreater, + NameLessThan = nameLessThan + }); + + Assert.Equal(expected, result.Items.Select(p => p.Name)); + Assert.Equal(expected.Length, result.TotalRecordCount); + } +} 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/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs index a28c1d6dfb..f717d9b665 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs @@ -29,6 +29,7 @@ public class FindExtrasTests { private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; private readonly Mock<IFileSystem> _fileSystemMock; + private readonly Mock<IItemRepository> _itemRepositoryMock; public FindExtrasTests() { @@ -36,8 +37,8 @@ public class FindExtrasTests fixture.Register(() => new NamingOptions()); var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>(); configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); - var itemRepository = fixture.Freeze<Mock<IItemRepository>>(); - itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null); + _itemRepositoryMock = fixture.Freeze<Mock<IItemRepository>>(); + _itemRepositoryMock.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null); _fileSystemMock = fixture.Freeze<Mock<IFileSystem>>(); _fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path }); @@ -482,6 +483,69 @@ public class FindExtrasTests } [Fact] + public void FindExtras_ExtraNamedByLocalMetadata_KeepsItsNameOnRescan() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List<string> + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var directoryService = new DirectoryService(_fileSystemMock.Object); + var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Trailer", extra.Name); + + // A local metadata file gives the extra a title of its own, which the refresh persists + extra.Name = "Cannes Teaser"; + _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra); + + var rescanned = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Cannes Teaser", rescanned.Name); + } + + [Fact] + public void FindExtras_ExtraKeptItsGeneratedName_IsRenumberedOnRescan() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List<string> + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer2.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var directoryService = new DirectoryService(_fileSystemMock.Object); + var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Trailer", extra.Name); + _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra); + + // A trailer sorting before the known one takes the first number, so the known one moves on + files.Add(new FileSystemMetadata + { + FullName = "/movies/Up (2009)/Up (2009)-trailer1.mkv", + IsDirectory = false + }); + + var rescanned = _libraryManager.FindExtras(owner, files, directoryService) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal("Trailer", rescanned["/movies/Up (2009)/Up (2009)-trailer1.mkv"]); + Assert.Equal("Trailer 2", rescanned["/movies/Up (2009)/Up (2009)-trailer2.mkv"]); + } + + [Fact] public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle() { var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ParentalRatingScoreTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ParentalRatingScoreTests.cs new file mode 100644 index 0000000000..7adf7ce3b3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ParentalRatingScoreTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +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.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library.LibraryManager; + +[Collection("LibraryManagerTests")] +public sealed class ParentalRatingScoreTests : IDisposable +{ + private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; + private readonly List<BaseItem> _saved = []; + private readonly ILibraryManager? _previousLibraryManager; + private readonly ILocalizationManager? _previousLocalizationManager; + + public ParentalRatingScoreTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + fixture.Freeze<Mock<IServerConfigurationManager>>() + .Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + fixture.Freeze<Mock<IItemPersistenceService>>() + .Setup(p => p.SaveItems(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<CancellationToken>())) + .Callback<IReadOnlyList<BaseItem>, CancellationToken>((items, _) => _saved.AddRange(items)); + + _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; + _previousLocalizationManager = BaseItem.LocalizationManager; + BaseItem.LibraryManager = _libraryManager; + + var localizationManagerMock = new Mock<ILocalizationManager>(); + localizationManagerMock.Setup(l => l.GetRatingScore("G", It.IsAny<string>())).Returns(new ParentalRatingScore(0, null)); + localizationManagerMock.Setup(l => l.GetRatingScore("XXX", It.IsAny<string>())).Returns(new ParentalRatingScore(1000, null)); + BaseItem.LocalizationManager = localizationManagerMock.Object; + Video.RecordingsManager ??= fixture.Create<IRecordingsManager>(); + } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.LocalizationManager = _previousLocalizationManager!; + } + + private static Movie CreateRatedMovie() => new() + { + Id = Guid.NewGuid(), + Name = "Up", + PreferredMetadataCountryCode = "us", + OfficialRating = "G", + InheritedParentalRatingValue = 0 + }; + + [Fact] + public async Task UpdateItemAsync_ChangedCustomRating_StoresItsScore() + { + var movie = CreateRatedMovie(); + movie.CustomRating = "XXX"; + + await _libraryManager.UpdateItemAsync(movie, null!, ItemUpdateType.MetadataEdit, CancellationToken.None); + + Assert.Equal(1000, Assert.Single(_saved).InheritedParentalRatingValue); + } + + [Fact] + public async Task UpdateItemAsync_ClearedRatings_ClearsScore() + { + var movie = CreateRatedMovie(); + movie.OfficialRating = null; + + await _libraryManager.UpdateItemAsync(movie, null!, ItemUpdateType.MetadataEdit, CancellationToken.None); + + Assert.Null(Assert.Single(_saved).InheritedParentalRatingValue); + } + + [Fact] + public void CreateItem_RatedItem_StoresItsScore() + { + var movie = CreateRatedMovie(); + movie.InheritedParentalRatingValue = null; + movie.CustomRating = "XXX"; + + _libraryManager.CreateItem(movie, null); + + Assert.Equal(1000, _saved.Single().InheritedParentalRatingValue); + } +} 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/MovieResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs index e1346a8436..743ac6055b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs @@ -10,6 +10,7 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using Moq; @@ -89,4 +90,113 @@ public class MovieResolverTests Assert.All(result.Items, item => Assert.IsType<Movie>(item)); Assert.Single(((Video)result.Items[0]).LocalAlternateVersions); } + + [Fact] + public void ResolveMultiple_GivenNumberedSampleFiles_IgnoresSamples() + { + var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); + + var parent = new Folder { Path = "/movies/La Chimera (2023)" }; + var files = new List<FileSystemMetadata> + { + new() { FullName = "/movies/La Chimera (2023)/La Chimera (2023).mkv", Name = "La Chimera (2023).mkv", IsDirectory = false }, + new() { FullName = "/movies/La Chimera (2023)/Sample1.mkv", Name = "Sample1.mkv", IsDirectory = false }, + new() { FullName = "/movies/La Chimera (2023)/Sample2.mkv", Name = "Sample2.mkv", IsDirectory = false } + }; + + var result = movieResolver.ResolveMultiple(parent, files, CollectionType.movies, Mock.Of<IDirectoryService>()); + + Assert.NotNull(result); + var movie = Assert.Single(result.Items); + Assert.IsType<Movie>(movie); + Assert.Equal(files[0].FullName, movie.Path); + } + + [Fact] + public void AllExtrasTypesFolderNames_ContainsSampleSingularAndPlural() + { + Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("sample")); + Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("Sample")); + Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("samples")); + } + + [Theory] + [InlineData("Sample")] + [InlineData("sample")] + [InlineData("SAMPLE")] + [InlineData("samples")] + public void ResolvePath_MovieFolderWithSampleSubfolder_ResolvesToMovie(string sampleDirName) + { + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(m => m.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions()); + libraryManager.Setup(m => m.IgnoreFile(It.IsAny<FileSystemMetadata>(), It.IsAny<BaseItem>())).Returns(false); + + var resolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); + var args = new ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + libraryManager.Object) + { + Parent = new Folder(), + CollectionType = CollectionType.movies, + FileInfo = new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)", + IsDirectory = true + }, + FileSystemChildren = new[] + { + new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)/Outer Colony (2026).mkv", + Name = "Outer Colony (2026).mkv" + }, + new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)/" + sampleDirName, + Name = sampleDirName, + IsDirectory = true + } + } + }; + + Assert.IsType<Movie>(resolver.ResolvePath(args)); + } + + [Fact] + public void ResolvePath_MovieFolderWithRealSubfolder_DoesNotResolveToSingleMovie() + { + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(m => m.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions()); + libraryManager.Setup(m => m.IgnoreFile(It.IsAny<FileSystemMetadata>(), It.IsAny<BaseItem>())).Returns(false); + + var resolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); + var args = new ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + libraryManager.Object) + { + Parent = new Folder(), + CollectionType = CollectionType.movies, + FileInfo = new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)", + IsDirectory = true + }, + FileSystemChildren = new[] + { + new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)/Outer Colony (2026).mkv", + Name = "Outer Colony (2026).mkv" + }, + new FileSystemMetadata + { + FullName = "/media/Outer Colony (2026)/Feature", + Name = "Feature", + IsDirectory = true + } + } + }; + + Assert.Null(resolver.ResolvePath(args)); + } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/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..89ca037256 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs @@ -0,0 +1,134 @@ +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.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Configuration; +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); + + var serverConfigurationManager = new Mock<IServerConfigurationManager>(); + serverConfigurationManager.Setup(scm => scm.Configuration) + .Returns(new ServerConfiguration()); + + _provider = new SqlSearchProvider( + CreateDbContextFactory(), + itemTypeLookup, + _libraryManager.Object, + userManager.Object, + CreateBaseItemRepository(itemTypeLookup), + serverConfigurationManager.Object); + } + + [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/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs index ba3127bc08..2ed16de15b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; using Emby.Server.Implementations.Library; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; @@ -8,6 +10,7 @@ using Jellyfin.Database.Providers.Sqlite; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; @@ -208,6 +211,43 @@ public sealed class UserDataManagerTests : IDisposable } [Fact] + public void SaveUserData_RowUnderRetiredKey_IsKeptInAgreement() + { + var item = CreateAudioBook(); + + using (var ctx = CreateDbContext()) + { + ctx.Users.Add(_user); + ctx.BaseItems.Add(new BaseItemEntity { Id = item.Id, Type = typeof(AudioBook).FullName! }); + ctx.UserData.Add(CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111)); + ctx.SaveChanges(); + } + + _userDataManager.SaveUserData( + _user, + item, + new UserItemData { Key = item.GetUserDataKeys()[0], Played = true }, + UserDataSaveReason.UpdateUserRating, + CancellationToken.None); + + using (var ctx = CreateDbContext()) + { + var rows = ctx.UserData.Where(e => e.ItemId.Equals(item.Id)).ToList(); + + // The retired-key row is what a re-added item reattaches by, so it survives, but it must + // not keep a playback position that holds the item in Continue Watching. + Assert.Equal( + item.GetUserDataKeys().Append("Author-Old Album-0001Old File Name").OrderBy(e => e, StringComparer.Ordinal), + rows.Select(e => e.CustomDataKey).OrderBy(e => e, StringComparer.Ordinal)); + Assert.All(rows, row => + { + Assert.True(row.Played); + Assert.Equal(0, row.PlaybackPositionTicks); + }); + } + } + + [Fact] public void GetUserData_NullUser_ThrowsArgumentNullException() { var item = CreateAudioBook(); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index d973076ed3..4e57f32b57 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -6,6 +6,7 @@ using BitFaster.Caching; using Emby.Server.Implementations.Localization; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -180,7 +181,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization await localizationManager.LoadAll(); var ratings = localizationManager.GetParentalRatings().ToList(); - Assert.Equal(24, ratings.Count); + Assert.Equal(34, ratings.Count); var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal)); Assert.NotNull(fsk); @@ -199,6 +200,47 @@ 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)] + [InlineData("ATP", "AR", 0, null)] + [InlineData("SAM 13", "AR", 13, null)] + [InlineData("SAM 16", "AR", 16, null)] + [InlineData("SAM 18", "AR", 18, null)] + [InlineData("SAM13", "AR", 13, null)] // Written without a space + [InlineData("AR-SAM 16", "AR", 16, null)] // Country prefix stripped against the configured country + [InlineData("AR-SAM 13", "US", 13, null)] // Country prefix resolved via the separator fallback + [InlineData("AR-SAM 18", "US", 18, null)] + [InlineData("AR-SAM13", "US", 13, null)] + [InlineData("SAM 18 C", "AR", 1001, null)] // Condicionada, same as "C" + [InlineData("Interdit aux moins de 12 ans", "FR", 12, null)] + [InlineData("Interdit aux moins de 18 ans", "FR", 18, null)] + [InlineData("X 18+", "AU", 1000, 0)] // Official spelling of the Australian X rating + [InlineData("X18+", "AU", 1000, 0)] + [InlineData("FSK18", "DE", 18, null)] // Written without a space + [InlineData("ab 18", "DE", 18, null)] // Written as the minimum age + [InlineData("DE:ab 6", "DE", 6, null)] + [InlineData("–12", "FR", 12, null)] // The CNC writes its minimum ages with an en dash + [InlineData("–16", "FR", 16, null)] + [InlineData("–18", "FR", 18, null)] + [InlineData("SU", "ID", 0, null)] // Indonesian broadcast classifications (KPI) + [InlineData("P", "ID", 2, null)] + [InlineData("A", "ID", 7, null)] + [InlineData("R", "ID", 13, null)] + [InlineData("D", "ID", 18, null)] + [InlineData("D18+", "ID", 18, null)] // Written with the minimum age, as broadcast since 2016 + [InlineData("R-BO", "ID", 13, null)] // Parental guidance does not change the age group + [InlineData("Dewasa", "ID", 18, null)] + [InlineData("ID-D", "ID", 18, null)] // TMDB style country prefix + [InlineData("ID-D", "US", 18, null)] // Country prefix resolved via the separator fallback + [InlineData("P", "US", 1000, null)] // Ambiguous outside Indonesia, preferred as the Portuguese "Pornográfico" public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore) { var localizationManager = Setup(new ServerConfiguration() @@ -213,12 +255,42 @@ 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)] [InlineData("12", 12, null)] [InlineData("42", 42, null)] [InlineData("9999", 9999, null)] + // The French CNC writes minimum ages as "-12" ("not for under 12s"). Parsing that as -12 would + // put the item below every MaxParentalRatingScore and bypass parental control entirely. + [InlineData("-10", 10, null)] + [InlineData("-12", 12, null)] + [InlineData("-16", 16, null)] + [InlineData("-18", 18, null)] public async Task GetRatingLevel_GivenValidAge_Success(string value, int? expectedScore, int? expectedSubScore) { var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "nl" }); @@ -241,6 +313,107 @@ 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] + // Providers list every spelling of a rating in a single field. Splitting such a list by its country + // prefix pairs the first entry's country with the last entry's rating, so it has to be split by '/' first. + [InlineData("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18", "de", 18, null)] + [InlineData("SE:15 / SE:15+ / SE:Från 15 år", "de", 15, null)] + [InlineData("FR:16 / US:12", "de", 16, null)] // The first entry that resolves wins + public async Task GetRatingScore_CountryPrefixedList_UsesFirstResolvingEntry(string value, string countryCode, int expectedScore, int? expectedSubScore) + { + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = countryCode }); + await localizationManager.LoadAll(); + + var score = localizationManager.GetRatingScore(value); + + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); + } + + [Fact] + public async Task GetRatingScore_ResolvedCountryPrefixedList_DoesNotWarn() + { + var logger = new Mock<ILogger<LocalizationManager>>(); + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object); + await localizationManager.LoadAll(); + + Assert.NotNull(localizationManager.GetRatingScore("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18")); + + logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception?>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never); + } + + [Fact] + public async Task GetRatingScore_ListWithoutKnownRating_WarnsOnce() + { + var logger = new Mock<ILogger<LocalizationManager>>(); + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object); + await localizationManager.LoadAll(); + + Assert.Null(localizationManager.GetRatingScore("DE:Unbekannt / DE:Unsinn")); + + logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception?>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Once); } [Theory] @@ -446,12 +619,12 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase)); } - private LocalizationManager Setup(ServerConfiguration config) + private LocalizationManager Setup(ServerConfiguration config, ILogger<LocalizationManager>? logger = null) { var mockConfiguration = new Mock<IServerConfigurationManager>(); mockConfiguration.SetupGet(x => x.Configuration).Returns(config); - return new LocalizationManager(mockConfiguration.Object, new NullLogger<LocalizationManager>()); + return new LocalizationManager(mockConfiguration.Object, logger ?? new NullLogger<LocalizationManager>()); } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Locking/OptimisticLockBehaviorTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Locking/OptimisticLockBehaviorTests.cs new file mode 100644 index 0000000000..1a91923f8d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Locking/OptimisticLockBehaviorTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Locking; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Polly.Utilities; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Locking; + +/// <summary> +/// The retries of <see cref="OptimisticLockBehavior"/> when the database stays locked. Polly's clock records the +/// waits instead of sleeping through them, and the save hooks never use the context, so none is created. +/// </summary> +public sealed class OptimisticLockBehaviorTests : IDisposable +{ + private static readonly TimeSpan[] _sleepDurations = + [ + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(250), + TimeSpan.FromMilliseconds(250), + TimeSpan.FromMilliseconds(250), + TimeSpan.FromMilliseconds(150), + TimeSpan.FromMilliseconds(150), + TimeSpan.FromMilliseconds(150), + TimeSpan.FromMilliseconds(500), + TimeSpan.FromMilliseconds(150), + TimeSpan.FromMilliseconds(500), + TimeSpan.FromMilliseconds(150), + TimeSpan.FromSeconds(3) + ]; + + private readonly List<TimeSpan> _waits = []; + private readonly OptimisticLockBehavior _behavior = new(NullLogger<OptimisticLockBehavior>.Instance); + + public OptimisticLockBehaviorTests() + { + SystemClock.Sleep = (wait, _) => _waits.Add(wait); + SystemClock.SleepAsync = (wait, _) => + { + _waits.Add(wait); + return Task.CompletedTask; + }; + } + + public void Dispose() + { + SystemClock.Reset(); + } + + [Fact] + public void OnSaveChanges_DatabaseLockedBriefly_WaitsTheConfiguredDurationsInOrder() + { + var attempts = 0; + + _behavior.OnSaveChanges(null!, () => + { + if (++attempts <= 5) + { + throw DatabaseLocked(); + } + }); + + Assert.Equal(6, attempts); + AssertWaits(5); + } + + [Fact] + public void OnSaveChanges_DatabaseStaysLocked_ThrowsTheDatabaseErrorAfterTheLastRetry() + { + var attempts = 0; + + Assert.Throws<SqliteException>(() => _behavior.OnSaveChanges(null!, () => + { + attempts++; + throw DatabaseLocked(); + })); + + Assert.Equal(_sleepDurations.Length + 1, attempts); + AssertWaits(_sleepDurations.Length); + } + + [Fact] + public async Task OnSaveChangesAsync_DatabaseStaysLocked_ThrowsTheDatabaseErrorAfterTheLastRetry() + { + var attempts = 0; + + await Assert.ThrowsAsync<SqliteException>(() => _behavior.OnSaveChangesAsync(null!, () => + { + attempts++; + throw DatabaseLocked(); + })); + + Assert.Equal(_sleepDurations.Length + 1, attempts); + AssertWaits(_sleepDurations.Length); + } + + private static SqliteException DatabaseLocked() => new("SQLite Error 5: 'database is locked'.", 5); + + private void AssertWaits(int retries) + { + Assert.Equal(retries, _waits.Count); + for (var i = 0; i < retries; i++) + { + // The configured duration plus a jitter of less than half of it. + Assert.InRange(_waits[i], _sleepDurations[i], _sleepDurations[i] * 1.5); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Security/AuthenticationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Security/AuthenticationManagerTests.cs new file mode 100644 index 0000000000..e46b88c67e --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Security/AuthenticationManagerTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Security; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Security +{ + public sealed class AuthenticationManagerTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly AuthenticationManager _authenticationManager; + + public AuthenticationManagerTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + // Create the schema + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); + + _authenticationManager = new AuthenticationManager(factory.Object); + } + + public void Dispose() + { + _connection.Dispose(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + [Fact] + public async Task CreateApiKey_ReturnsInfoMatchingStoredKey() + { + var created = await _authenticationManager.CreateApiKey("test-app"); + + Assert.Equal("test-app", created.AppName); + Assert.False(string.IsNullOrEmpty(created.AccessToken)); + + var keys = await _authenticationManager.GetApiKeys(); + var stored = Assert.Single(keys); + Assert.Equal(created.AppName, stored.AppName); + Assert.Equal(created.AccessToken, stored.AccessToken); + } + + [Fact] + public async Task CreateApiKey_WithMultipleKeys_ReturnsDistinctTokens() + { + var first = await _authenticationManager.CreateApiKey("app1"); + var second = await _authenticationManager.CreateApiKey("app2"); + + Assert.NotEqual(first.AccessToken, second.AccessToken); + } + } +} 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..13081dcff9 --- /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>A <see cref="Task"/> representing the asynchronous unit test.</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/SessionManager/RewatchPlayStateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/RewatchPlayStateTests.cs new file mode 100644 index 0000000000..dd2e935feb --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/RewatchPlayStateTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +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.TV; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SessionManager; + +[Collection("LibraryManagerTests")] +public sealed class RewatchPlayStateTests : IDisposable +{ + private const long ResumeThresholdTicks = 100 * TimeSpan.TicksPerSecond; + + private static readonly DateTime _previousPlayDate = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private readonly ILibraryManager? _previousLibraryManager; + + public RewatchPlayStateTests() + { + _previousLibraryManager = BaseItem.LibraryManager; + + // Resolves the (empty) alternate versions of the episode when the stop propagates its played state + BaseItem.LibraryManager = Mock.Of<ILibraryManager>(); + } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager!; + } + + [Theory] + [InlineData(3 * TimeSpan.TicksPerSecond, false)] + [InlineData(600 * TimeSpan.TicksPerSecond, true)] + public async Task RewatchOfPlayedEpisode_OnlyCountsPastResumeThreshold(long stopPositionTicks, bool expectDateUpdated) + { + var data = new UserItemData { Key = "episode", Played = true, LastPlayedDate = _previousPlayDate }; + await using var sessionManager = CreateSessionManager(data); + var user = new User("test", "default", "default"); + var episode = new Episode { Id = Guid.NewGuid() }; + + Invoke(sessionManager, "OnPlaybackStart", user, episode); + Assert.Equal(_previousPlayDate, data.LastPlayedDate); + + Invoke(sessionManager, "OnPlaybackStopped", user, episode, stopPositionTicks, false); + Assert.True(data.Played); + Assert.Equal(expectDateUpdated, data.LastPlayedDate > _previousPlayDate); + } + + [Fact] + public async Task PlaybackStartOfUnplayedEpisode_UpdatesLastPlayedDate() + { + var data = new UserItemData { Key = "episode", LastPlayedDate = _previousPlayDate }; + await using var sessionManager = CreateSessionManager(data); + + Invoke(sessionManager, "OnPlaybackStart", new User("test", "default", "default"), new Episode { Id = Guid.NewGuid() }); + + Assert.True(data.LastPlayedDate > _previousPlayDate); + } + + private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(UserItemData data) + { + var userDataManager = new Mock<IUserDataManager>(); + userDataManager + .Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())) + .Returns(data); + userDataManager + .Setup(m => m.UpdatePlayState(It.IsAny<BaseItem>(), It.IsAny<UserItemData>(), It.IsAny<long?>())) + .Callback<BaseItem, UserItemData, long?>((_, d, position) => d.PlaybackPositionTicks = position >= ResumeThresholdTicks ? position.Value : 0) + .Returns(false); + + return new Emby.Server.Implementations.Session.SessionManager( + NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance, + Mock.Of<IEventManager>(), + userDataManager.Object, + Mock.Of<IServerConfigurationManager>(), + Mock.Of<ILibraryManager>(), + Mock.Of<IUserManager>(), + Mock.Of<IMusicManager>(), + Mock.Of<IDtoService>(), + Mock.Of<IImageProcessor>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<IDeviceManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IHostApplicationLifetime>()); + } + + private static void Invoke(Emby.Server.Implementations.Session.SessionManager sessionManager, string name, params object?[] args) + { + var parameterTypes = Array.ConvertAll(args, a => a!.GetType()); + parameterTypes[1] = typeof(BaseItem); + if (args.Length > 2) + { + parameterTypes[2] = typeof(long?); + } + + typeof(Emby.Server.Implementations.Session.SessionManager) + .GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic, parameterTypes)! + .Invoke(sessionManager, args); + } +} 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/Trickplay/TrickplayManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Trickplay/TrickplayManagerTests.cs new file mode 100644 index 0000000000..fa33806dcc --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Trickplay/TrickplayManagerTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.Tests.Item; +using Jellyfin.Server.Implementations.Trickplay; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Trickplay; + +public sealed class TrickplayManagerTests : SqliteDbTestFixture +{ + [Fact] + public async Task DeleteTrickplayDataAsync_DisposesDbContext() + { + var contexts = new List<JellyfinDbContext>(); + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + var context = CreateDbContext(); + contexts.Add(context); + return context; + }); + + // Only the database context factory is used when deleting trickplay data. + var trickplayManager = new TrickplayManager( + NullLogger<TrickplayManager>.Instance, + null!, + null!, + null!, + null!, + null!, + factory.Object, + null!, + null!); + + await trickplayManager.DeleteTrickplayDataAsync(Guid.NewGuid(), CancellationToken.None); + + var context = Assert.Single(contexts); + Assert.Throws<ObjectDisposedException>(() => context.Model); + } +} 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/DisplayPreferencesManagerConcurrentCreateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerConcurrentCreateTests.cs new file mode 100644 index 0000000000..72ca680cf2 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerConcurrentCreateTests.cs @@ -0,0 +1,77 @@ +using System; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Tests.Item; +using Jellyfin.Server.Implementations.Users; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users; + +/// <summary> +/// Two first-ever requests for the same preferences can both find nothing and both insert; the unique index lets one +/// through. The other has to return the stored preferences instead of failing. +/// </summary> +public sealed class DisplayPreferencesManagerConcurrentCreateTests : SqliteDbTestFixture +{ + private const string Client = "client"; + + private static readonly Guid _userId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _itemId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + private readonly CompetingInsert _competingInsert; + private readonly DisplayPreferencesManager _manager; + + public DisplayPreferencesManagerConcurrentCreateTests() + : this(new CompetingInsert()) + { + } + + private DisplayPreferencesManagerConcurrentCreateTests(CompetingInsert competingInsert) + : base(competingInsert) + { + _competingInsert = competingInsert; + _manager = new DisplayPreferencesManager(CreateDbContextFactory()); + + using var context = CreateDbContext(); + context.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId }); + context.SaveChanges(); + } + + [Fact] + public void GetDisplayPreferences_AnotherRequestStoresThemFirst_ReturnsTheStoredPreferences() + { + var storedId = 0; + _competingInsert.Before = () => + { + using var context = CreateDbContext(); + var stored = new DisplayPreferences(_userId, _itemId, Client); + context.DisplayPreferences.Add(stored); + context.SaveChanges(); + storedId = stored.Id; + }; + + var preferences = _manager.GetDisplayPreferences(_userId, _itemId, Client); + + Assert.NotEqual(0, storedId); + Assert.Equal(storedId, preferences.Id); + using var check = CreateDbContext(); + Assert.Single(check.DisplayPreferences); + } + + /// <summary> + /// Stores the other request's row just before the next save, after the manager has looked for the preferences. + /// </summary> + private sealed class CompetingInsert : SaveChangesInterceptor + { + public Action? Before { get; set; } + + public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result) + { + // Disarmed first, because the competing save goes through this interceptor as well. + var before = Before; + Before = null; + before?.Invoke(); + return result; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerFailedWriteTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerFailedWriteTests.cs new file mode 100644 index 0000000000..13688c7574 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerFailedWriteTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using Jellyfin.Server.Implementations.Tests.Item; +using Jellyfin.Server.Implementations.Users; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users; + +public sealed class DisplayPreferencesManagerFailedWriteTests : SqliteDbTestFixture +{ + private const string Client = "client"; + + private static readonly Guid _userId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _itemId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + private readonly StatementInterceptor _statements; + private readonly DisplayPreferencesManager _manager; + + public DisplayPreferencesManagerFailedWriteTests() + : this(new StatementInterceptor()) + { + } + + private DisplayPreferencesManagerFailedWriteTests(StatementInterceptor statements) + : base(statements) + { + _statements = statements; + _manager = new DisplayPreferencesManager(CreateDbContextFactory()); + } + + [Fact] + public void SetCustomItemDisplayPreferences_InsertFails_KeepsThePreviousPreferences() + { + _manager.SetCustomItemDisplayPreferences(_userId, _itemId, Client, new Dictionary<string, string?> { ["first"] = "1" }); + + _statements.FailInserts = true; + Assert.Throws<DbUpdateException>(() => _manager.SetCustomItemDisplayPreferences(_userId, _itemId, Client, new Dictionary<string, string?> { ["second"] = "2" })); + + Assert.Equal( + new Dictionary<string, string?> { ["first"] = "1" }, + _manager.ListCustomItemDisplayPreferences(_userId, _itemId, Client)); + } + + [Fact] + public void SetCustomItemDisplayPreferences_AnotherRequestStoredAKeyAfterTheDelete_StoresThePreferencesItWasGiven() + { + _manager.SetCustomItemDisplayPreferences(_userId, _itemId, Client, new Dictionary<string, string?> { ["first"] = "1" }); + + // The delete misses the row, as it does when another request stores it after this one's delete, so the unique + // index rejects this request's insert. + _statements.SkipNextDelete = true; + _manager.SetCustomItemDisplayPreferences(_userId, _itemId, Client, new Dictionary<string, string?> { ["first"] = "2" }); + + Assert.Equal( + new Dictionary<string, string?> { ["first"] = "2" }, + _manager.ListCustomItemDisplayPreferences(_userId, _itemId, Client)); + } + + /// <summary> + /// Fails inserts into CustomItemDisplayPreferences with the error SQLite reports when the unique index rejects a row, or + /// leaves out the next delete from it. + /// </summary> + private sealed class StatementInterceptor : DbCommandInterceptor + { + public bool FailInserts { get; set; } + + public bool SkipNextDelete { get; set; } + + public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result) + { + FailInsert(command); + return result; + } + + public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result) + { + if (SkipNextDelete && command.CommandText.StartsWith("DELETE FROM \"CustomItemDisplayPreferences\"", StringComparison.Ordinal)) + { + SkipNextDelete = false; + return InterceptionResult<int>.SuppressWithResult(0); + } + + FailInsert(command); + return result; + } + + private void FailInsert(DbCommand command) + { + if (FailInserts && command.CommandText.StartsWith("INSERT INTO \"CustomItemDisplayPreferences\"", StringComparison.Ordinal)) + { + throw new SqliteException("SQLite Error 19: 'UNIQUE constraint failed: CustomItemDisplayPreferences.UserId, CustomItemDisplayPreferences.ItemId, CustomItemDisplayPreferences.Client, CustomItemDisplayPreferences.Key'.", 19); + } + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerTests.cs new file mode 100644 index 0000000000..4110eb513f --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerTests.cs @@ -0,0 +1,53 @@ +using System; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Tests.Item; +using Jellyfin.Server.Implementations.Users; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users; + +/// <summary> +/// Item display preferences are looked up by the item they belong to, so they have to be stored under it as well: +/// a row stored under any other item is never found again, and every later request stores another one. +/// </summary> +public sealed class DisplayPreferencesManagerTests : SqliteDbTestFixture +{ + private const string Client = "client"; + + private static readonly Guid _userId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _itemId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + private readonly DisplayPreferencesManager _manager; + + public DisplayPreferencesManagerTests() + { + _manager = new DisplayPreferencesManager(CreateDbContextFactory()); + + using var context = CreateDbContext(); + context.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId }); + context.SaveChanges(); + } + + [Fact] + public void GetItemDisplayPreferences_NewItem_StoresThePreferencesUnderThatItem() + { + var preferences = _manager.GetItemDisplayPreferences(_userId, _itemId, Client); + + Assert.Equal(_itemId, preferences.ItemId); + + using var context = CreateDbContext(); + Assert.Equal(_itemId, Assert.Single(context.ItemDisplayPreferences).ItemId); + } + + [Fact] + public void GetItemDisplayPreferences_SameItemTwice_ReturnsTheStoredPreferences() + { + var first = _manager.GetItemDisplayPreferences(_userId, _itemId, Client); + var second = _manager.GetItemDisplayPreferences(_userId, _itemId, Client); + + Assert.Equal(first.Id, second.Id); + + using var context = CreateDbContext(); + Assert.Single(context.ItemDisplayPreferences); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs index c940f92109..e91ebdf1b6 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data; using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.Sqlite; @@ -17,6 +18,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Events; using MediaBrowser.Model.Cryptography; +using MediaBrowser.Model.Users; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; @@ -120,6 +122,27 @@ public sealed class UserManagerUpdateUserTests : IDisposable } [Fact] + public async Task UpdatePolicyAsync_RaisesOnUserUpdated() + { + var user = await _userManager.CreateUserAsync("policyeventuser"); + + User? updated = null; + _userManager.OnUserUpdated += (_, e) => updated = e.Argument; + + await _userManager.UpdatePolicyAsync( + user.Id, + new UserPolicy + { + EnableAllDevices = false, + AuthenticationProviderId = user.AuthenticationProviderId, + PasswordResetProviderId = user.PasswordResetProviderId + }); + + Assert.NotNull(updated); + Assert.Equal(user.Id, updated.Id); + } + + [Fact] public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges() { var user = await _userManager.CreateUserAsync("policyuser"); diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs index 79b9d1e2c5..3d0840b776 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs @@ -1,12 +1,19 @@ using System; +using System.Globalization; +using System.IO; using System.Linq; using System.Net; using System.Net.Http.Json; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.LibraryStructureDto; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -218,4 +225,122 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl using var response = await client.DeleteAsync("Library/VirtualFolders?name=test&refreshLibrary=true", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } + + [Fact] + [Priority(1)] + public async Task RenameLibrary_UserHasLibraryPreferences_RemapsLibraryId() + { + const string Name = "rename-prefs-old"; + const string NewName = "rename-prefs-new"; + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var libraryManager = _factory.Services.GetRequiredService<ILibraryManager>(); + var userManager = _factory.Services.GetRequiredService<IUserManager>(); + var rootFolderPath = _factory.Services.GetRequiredService<IServerApplicationPaths>().DefaultUserViewsPath; + + var body = new AddVirtualFolderDto() { LibraryOptions = new LibraryOptions() }; + using var createResponse = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&collectionType=movies&refreshLibrary=true", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode); + + var oldId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name), typeof(CollectionFolder)); + var newId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, NewName), typeof(CollectionFolder)); + Assert.NotEqual(oldId, newId); + + var oldLibrary = libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().Single(f => f.Id.Equals(oldId)); + + var otherId = Guid.NewGuid(); + var user = await userManager.CreateUserAsync("rename-prefs-user"); + user.SetPermission(PermissionKind.EnableAllFolders, false); + user.SetPreference(PreferenceKind.EnabledFolders, [otherId, oldId, newId]); + user.SetPreference(PreferenceKind.BlockedMediaFolders, [oldId]); + user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, [oldId, otherId]); + user.SetPreference(PreferenceKind.LatestItemExcludes, [oldId]); + + // The views built from the library take its id and name as input, so they move with it. + var oldShadowId = libraryManager.GetShadowView(oldLibrary, oldLibrary.CollectionType, string.Empty).Id; + var oldUserViewId = libraryManager.GetNamedView(user, oldLibrary.Name, oldId, oldLibrary.CollectionType, string.Empty).Id; + user.SetPreference(PreferenceKind.MyMediaExcludes, [otherId.ToString(), oldId.ToString(), oldShadowId.ToString("N", CultureInfo.InvariantCulture)]); + user.SetPreference(PreferenceKind.GroupedFolders, [oldId]); + user.SetPreference(PreferenceKind.OrderedViews, [otherId.ToString(), oldUserViewId.ToString(), oldShadowId.ToString(), oldId.ToString()]); + user.SetPreference(PreferenceKind.BlockedTags, ["keep"]); + await userManager.UpdateUserAsync(user); + + using var renameResponse = await client.PostAsync( + $"Library/VirtualFolders/Name?name={Name}&newName={NewName}&refreshLibrary=true", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + + var reloaded = userManager.GetUserById(user.Id)!; + Assert.Equal([otherId, newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders)); + Assert.Equal([newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders)); + Assert.Equal([newId, otherId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.EnableContentDeletionFromFolders)); + Assert.Equal([newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes)); + // The endpoint refreshes in the background; make sure the renamed library has been picked up. + await libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true); + var newLibrary = libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().Single(f => f.Id.Equals(newId)); + var newShadowId = libraryManager.GetShadowView(newLibrary, newLibrary.CollectionType, string.Empty).Id; + var newUserViewId = libraryManager.GetNamedView(reloaded, newLibrary.Name, newId, newLibrary.CollectionType, string.Empty).Id; + Assert.NotEqual(oldShadowId, newShadowId); + Assert.NotEqual(oldUserViewId, newUserViewId); + Assert.Equal( + [otherId.ToString(), newId.ToString(), newShadowId.ToString("N", CultureInfo.InvariantCulture)], + reloaded.GetPreference(PreferenceKind.MyMediaExcludes)); + Assert.Equal([newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.GroupedFolders)); + Assert.Equal( + [otherId.ToString(), newUserViewId.ToString(), newShadowId.ToString(), newId.ToString()], + reloaded.GetPreference(PreferenceKind.OrderedViews)); + Assert.Equal(["keep"], reloaded.GetPreference(PreferenceKind.BlockedTags)); + + // With the block list cleared, the enabled list alone must let the user see the renamed library. + reloaded.SetPreference(PreferenceKind.BlockedMediaFolders, Array.Empty<Guid>()); + await userManager.UpdateUserAsync(reloaded); + Assert.True(newLibrary.IsVisible(userManager.GetUserById(user.Id)!)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={NewName}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } + + [Fact] + [Priority(1)] + public async Task RenameLibrary_CapitalizationOnly_LeavesPreferencesAlone() + { + const string Name = "rename-case-test"; + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var libraryManager = _factory.Services.GetRequiredService<ILibraryManager>(); + var userManager = _factory.Services.GetRequiredService<IUserManager>(); + var rootFolderPath = _factory.Services.GetRequiredService<IServerApplicationPaths>().DefaultUserViewsPath; + + var body = new AddVirtualFolderDto() { LibraryOptions = new LibraryOptions() }; + using var createResponse = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode); + + var oldId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name), typeof(CollectionFolder)); + var newId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name.ToUpperInvariant()), typeof(CollectionFolder)); + var oldLibrary = libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().Single(f => f.Id.Equals(oldId)); + var oldShadowId = libraryManager.GetShadowView(oldLibrary, oldLibrary.CollectionType, string.Empty).Id; + var newShadowId = libraryManager.GetShadowViewId(Name.ToUpperInvariant(), newId, oldLibrary.CollectionType); + + var user = await userManager.CreateUserAsync("rename-case-user"); + user.SetPreference(PreferenceKind.EnabledFolders, [oldId]); + user.SetPreference(PreferenceKind.MyMediaExcludes, [oldShadowId]); + await userManager.UpdateUserAsync(user); + + using var renameResponse = await client.PostAsync( + $"Library/VirtualFolders/Name?name={Name}&newName={Name.ToUpperInvariant()}&refreshLibrary=false", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + + Assert.Equal([newId], userManager.GetUserById(user.Id)!.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders)); + Assert.Equal([newShadowId], userManager.GetUserById(user.Id)!.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={Name.ToUpperInvariant()}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } } 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.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index 54f443de2d..bcd6cd9bf4 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -29,6 +29,7 @@ namespace Jellyfin.Server.Integration.Tests { private static readonly string _testPathRoot = Path.Combine(Path.GetTempPath(), "jellyfin-test-data"); private readonly ConcurrentBag<IDisposable> _disposableComponents = new ConcurrentBag<IDisposable>(); + private readonly string _webHostPathRoot; /// <summary> /// Initializes static members of the <see cref="JellyfinApplicationFactory"/> class. @@ -42,6 +43,23 @@ namespace Jellyfin.Server.Integration.Tests StartupHelpers.PerformStaticInitialization(); } + /// <summary> + /// Initializes a new instance of the <see cref="JellyfinApplicationFactory"/> class using a new temporary directory. + /// </summary> + public JellyfinApplicationFactory() + : this(Path.Combine(_testPathRoot, "test-host-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName()))) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="JellyfinApplicationFactory"/> class. + /// </summary> + /// <param name="webHostPathRoot">The directory the application paths are created in. Reusing it starts the same server again.</param> + protected JellyfinApplicationFactory(string webHostPathRoot) + { + _webHostPathRoot = webHostPathRoot; + } + /// <inheritdoc/> protected override IHostBuilder CreateHostBuilder() { @@ -57,7 +75,7 @@ namespace Jellyfin.Server.Integration.Tests var commandLineOpts = new StartupOptions(); // Use a temporary directory for the application paths - var webHostPathRoot = Path.Combine(_testPathRoot, "test-host-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); + var webHostPathRoot = _webHostPathRoot; Directory.CreateDirectory(Path.Combine(webHostPathRoot, "logs")); Directory.CreateDirectory(Path.Combine(webHostPathRoot, "config")); Directory.CreateDirectory(Path.Combine(webHostPathRoot, "cache")); diff --git a/tests/Jellyfin.Server.Integration.Tests/StartOverTests.cs b/tests/Jellyfin.Server.Integration.Tests/StartOverTests.cs new file mode 100644 index 0000000000..b8c149891f --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/StartOverTests.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using Emby.Server.Implementations.Serialization; +using Jellyfin.Api.Models.StartupDtos; +using Jellyfin.Database.Implementations; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests; + +/// <summary> +/// Covers starting a server that has been set up before after its database has been deleted. +/// </summary> +public sealed class StartOverTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "jellyfin-test-data", "start-over-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + + [Fact] + public async Task DeletedDatabase_WizardReset_StartsAsNewServer() + { + var paths = await SetUpServerAndDeleteDatabaseAsync(); + SetWizardCompleted(paths, false); + + using var factory = new SameRootApplicationFactory(_root); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/Startup/Configuration", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var dbContextFactory = factory.Services.GetRequiredService<IDbContextFactory<JellyfinDbContext>>(); + await using var context = await dbContextFactory.CreateDbContextAsync(TestContext.Current.CancellationToken); + var applied = (await context.Database.GetAppliedMigrationsAsync(TestContext.Current.CancellationToken)).ToHashSet(StringComparer.Ordinal); + Assert.All(context.GetService<IMigrationsAssembly>().Migrations.Keys, id => Assert.Contains(id, applied)); + } + + [Fact] + public async Task DeletedDatabase_WizardStillCompleted_RefusesToStart() + { + var paths = await SetUpServerAndDeleteDatabaseAsync(); + var systemConfiguration = await File.ReadAllBytesAsync(paths.SystemConfigurationFilePath, TestContext.Current.CancellationToken); + + using var factory = new SameRootApplicationFactory(_root); + var exception = Assert.ThrowsAny<Exception>(() => factory.CreateClient()); + + var guard = exception as InvalidOperationException ?? exception.InnerException as InvalidOperationException; + Assert.NotNull(guard); + Assert.Contains("the database does not exist", guard.Message, StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(paths.DataPath, "jellyfin.db*")); + Assert.Equal(systemConfiguration, await File.ReadAllBytesAsync(paths.SystemConfigurationFilePath, TestContext.Current.CancellationToken)); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + try + { + Directory.Delete(_root, true); + } + catch (IOException) + { + // Best effort, a locked file must not fail the test. + } + } + + private static void SetWizardCompleted(IApplicationPaths paths, bool completed) + { + var serializer = new MyXmlSerializer(); + var configuration = (ServerConfiguration)serializer.DeserializeFromFile(typeof(ServerConfiguration), paths.SystemConfigurationFilePath)!; + configuration.IsStartupWizardCompleted = completed; + serializer.SerializeToFile(configuration, paths.SystemConfigurationFilePath); + } + + private async Task<IApplicationPaths> SetUpServerAndDeleteDatabaseAsync() + { + IApplicationPaths paths; + using (var factory = new SameRootApplicationFactory(_root)) + { + using var client = factory.CreateClient(); + paths = factory.Services.GetRequiredService<IApplicationPaths>(); + + using (var response = await client.GetAsync("/Startup/User", TestContext.Current.CancellationToken)) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + var user = new StartupUserDto { Name = "StartOver", Password = "StartOver" }; + using (var response = await client.PostAsJsonAsync("/Startup/User", user, _jsonOptions, TestContext.Current.CancellationToken)) + { + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + } + + // Completing the wizard through the API would queue a library scan that can still hold the database file when + // it is deleted below, so the flag is set on the stopped server instead. + SetWizardCompleted(paths, true); + SqliteConnection.ClearAllPools(); + foreach (var file in Directory.GetFiles(paths.DataPath, "jellyfin.db*")) + { + File.Delete(file); + } + + return paths; + } + + private sealed class SameRootApplicationFactory(string webHostPathRoot) : JellyfinApplicationFactory(webHostPathRoot); +} 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/FixNullEncoderPresetTests.cs b/tests/Jellyfin.Server.Tests/Migrations/FixNullEncoderPresetTests.cs new file mode 100644 index 0000000000..b41ecbb8ed --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/FixNullEncoderPresetTests.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Serialization; +using Emby.Server.Implementations; +using Jellyfin.Server.Migrations.PreStartupRoutines; +using Jellyfin.Server.Migrations.Routines; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +public sealed class FixNullEncoderPresetTests : IDisposable +{ + private readonly string _configurationDirectory; + private readonly string _encodingConfigurationPath; + private readonly FixNullEncoderPreset _migration; + + public FixNullEncoderPresetTests() + { + _configurationDirectory = Directory.CreateTempSubdirectory("jellyfin-migration-test-").FullName; + _encodingConfigurationPath = Path.Combine(_configurationDirectory, "encoding.xml"); + + var applicationPaths = new Mock<IApplicationPaths>(); + applicationPaths.SetupGet(paths => paths.ConfigurationDirectoryPath).Returns(_configurationDirectory); + _migration = new FixNullEncoderPreset(applicationPaths.Object, NullLogger<FixNullEncoderPreset>.Instance); + } + + [Fact] + public async Task PerformAsync_ReplacesNullEncoderPresetWithAuto() + { + const string Configuration = """ + <?xml version="1.0" encoding="utf-8"?> + <EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <HardwareAccelerationType>qsv</HardwareAccelerationType> + <EncoderPreset xsi:nil="true" /> + <EnableHardwareEncoding>true</EnableHardwareEncoding> + </EncodingOptions> + """; + await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken); + + await _migration.PerformAsync(TestContext.Current.CancellationToken); + + var serializer = new XmlSerializer(typeof(EncodingOptions)); + using var reader = XmlReader.Create(_encodingConfigurationPath); + var encodingOptions = Assert.IsType<EncodingOptions>(serializer.Deserialize(reader)); + Assert.Equal(EncoderPreset.auto, encodingOptions.EncoderPreset); + Assert.Equal(HardwareAccelerationType.qsv, encodingOptions.HardwareAccelerationType); + Assert.True(encodingOptions.EnableHardwareEncoding); + } + + [Fact] + public async Task PerformAsync_LeavesValidEncoderPresetUnchanged() + { + const string Configuration = """ + <?xml version="1.0" encoding="utf-8"?> + <EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <EncoderPreset>fast</EncoderPreset> + </EncodingOptions> + """; + await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken); + + await _migration.PerformAsync(TestContext.Current.CancellationToken); + + Assert.Equal(Configuration, await File.ReadAllTextAsync(_encodingConfigurationPath, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task PerformAsync_LeavesMalformedConfigurationUnchanged() + { + const string Configuration = "<EncodingOptions><EncoderPreset xsi:nil=\"true\" />"; + await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken); + + await _migration.PerformAsync(TestContext.Current.CancellationToken); + + Assert.Equal(Configuration, await File.ReadAllTextAsync(_encodingConfigurationPath, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task MigrateEncodingOptions_UsesAutoForNullEncoderPreset() + { + const string Configuration = """ + <?xml version="1.0" encoding="utf-8"?> + <EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <HardwareAccelerationType>qsv</HardwareAccelerationType> + <TonemappingRange>tv</TonemappingRange> + <EncoderPreset xsi:nil="true" /> + <EnableHardwareEncoding>true</EnableHardwareEncoding> + </EncodingOptions> + """; + await File.WriteAllTextAsync(_encodingConfigurationPath, Configuration, TestContext.Current.CancellationToken); + var applicationPaths = new ServerApplicationPaths( + _configurationDirectory, + _configurationDirectory, + _configurationDirectory, + _configurationDirectory, + _configurationDirectory); + + var migration = new MigrateEncodingOptions(applicationPaths, NullLoggerFactory.Instance); + migration.Perform(); + + var serializer = new XmlSerializer(typeof(EncodingOptions)); + using var reader = XmlReader.Create(_encodingConfigurationPath); + var encodingOptions = Assert.IsType<EncodingOptions>(serializer.Deserialize(reader)); + Assert.Equal(EncoderPreset.auto, encodingOptions.EncoderPreset); + Assert.Equal(HardwareAccelerationType.qsv, encodingOptions.HardwareAccelerationType); + Assert.True(encodingOptions.EnableHardwareEncoding); + } + + public void Dispose() + { + Directory.Delete(_configurationDirectory, true); + } +} diff --git a/tests/Jellyfin.Server.Tests/Migrations/HarmonizeConflictingUserDataTests.cs b/tests/Jellyfin.Server.Tests/Migrations/HarmonizeConflictingUserDataTests.cs new file mode 100644 index 0000000000..ba0829cb56 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/HarmonizeConflictingUserDataTests.cs @@ -0,0 +1,138 @@ +using System; +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.Implementations.Item; +using Jellyfin.Server.Migrations.Routines; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +public sealed class HarmonizeConflictingUserDataTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private readonly Guid _untouchedId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + public HarmonizeConflictingUserDataTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using var context = CreateDbContext(); + context.Database.EnsureCreated(); + } + + public void Dispose() => _connection.Dispose(); + + [Fact] + public async Task Perform_ConflictingRows_CollapsesToMostRecentPlay() + { + using (var ctx = CreateDbContext()) + { + ctx.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId }); + ctx.BaseItems.Add(new BaseItemEntity { Id = _itemId, Type = typeof(Folder).FullName! }); + ctx.BaseItems.Add(new BaseItemEntity { Id = _untouchedId, Type = typeof(Folder).FullName! }); + + // One item holding rows from two different eras of itself... + ctx.UserData.Add(Row(_itemId, "497698", new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true)); + ctx.UserData.Add(Row(_itemId, "tt3480822", new DateTime(2021, 12, 31, 0, 0, 0, DateTimeKind.Utc), playCount: 7, positionTicks: 490)); + + // ...and one whose rows already agree. + ctx.UserData.Add(Row(_untouchedId, "121", new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 2, positionTicks: 77)); + ctx.UserData.Add(Row(_untouchedId, "tt0167261", new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 2, positionTicks: 77)); + + await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await CreateMigration().PerformAsync(TestContext.Current.CancellationToken); + + using (var ctx = CreateDbContext()) + { + var rows = ctx.UserData.Where(e => e.ItemId.Equals(_itemId)).ToList(); + Assert.Equal(2, rows.Count); + Assert.All(rows, row => + { + Assert.True(row.Played); + Assert.Equal(0, row.PlaybackPositionTicks); + Assert.Equal(9, row.PlayCount); + }); + + Assert.All(ctx.UserData.Where(e => e.ItemId.Equals(_untouchedId)), row => Assert.Equal(77, row.PlaybackPositionTicks)); + } + } + + [Fact] + public async Task Perform_DetachedRows_AreLeftAlone() + { + using (var ctx = CreateDbContext()) + { + ctx.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId }); + + // Detached rows belong to no item, so they are not a conflict and must keep their state + // for the next item that claims one of their keys. + ctx.UserData.Add(Row(BaseItemRepository.PlaceholderId, "497698", new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true)); + ctx.UserData.Add(Row(BaseItemRepository.PlaceholderId, "tt3480822", new DateTime(2021, 12, 31, 0, 0, 0, DateTimeKind.Utc), playCount: 7, positionTicks: 490)); + + await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await CreateMigration().PerformAsync(TestContext.Current.CancellationToken); + + using (var ctx = CreateDbContext()) + { + var rows = ctx.UserData.Where(e => e.ItemId.Equals(BaseItemRepository.PlaceholderId)).OrderBy(e => e.CustomDataKey).ToList(); + Assert.Equal(0, rows[0].PlaybackPositionTicks); + Assert.Equal(490, rows[1].PlaybackPositionTicks); + } + } + + private UserData Row(Guid itemId, string key, DateTime lastPlayed, int playCount, long positionTicks, bool played = false) + { + return new UserData + { + ItemId = itemId, + Item = null, + UserId = _userId, + User = null, + CustomDataKey = key, + LastPlayedDate = lastPlayed, + PlayCount = playCount, + PlaybackPositionTicks = positionTicks, + Played = played + }; + } + + private JellyfinDbContext CreateDbContext() => new( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(new Mock<IApplicationPaths>().Object, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + + private HarmonizeConflictingUserData CreateMigration() + { + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext); + + return new HarmonizeConflictingUserData( + new StartupLogger<HarmonizeConflictingUserData>(NullLogger<HarmonizeConflictingUserData>.Instance), + factory.Object); + } +} diff --git a/tests/Jellyfin.Server.Tests/Migrations/JellyfinMigrationServiceTests.cs b/tests/Jellyfin.Server.Tests/Migrations/JellyfinMigrationServiceTests.cs new file mode 100644 index 0000000000..1d789f24a4 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/JellyfinMigrationServiceTests.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Emby.Server.Implementations; +using Emby.Server.Implementations.Configuration; +using Emby.Server.Implementations.Serialization; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.DatabaseConfiguration; +using Jellyfin.Server.Implementations.Extensions; +using Jellyfin.Server.Migrations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +/// <summary> +/// Covers how the migration service treats the database of a server that has been set up before. +/// </summary> +public sealed class JellyfinMigrationServiceTests : IDisposable +{ + private readonly string _root; + private readonly ServerApplicationPaths _paths; + private readonly List<ServiceProvider> _serviceProviders = []; + + public JellyfinMigrationServiceTests() + { + _root = Path.Combine(Path.GetTempPath(), "jellyfin-migration-service-tests", Guid.NewGuid().ToString("N")); + _paths = new ServerApplicationPaths( + Path.Combine(_root, "data"), + Path.Combine(_root, "log"), + Path.Combine(_root, "config"), + Path.Combine(_root, "cache"), + Path.Combine(_root, "web")); + Directory.CreateDirectory(_paths.DataPath); + Directory.CreateDirectory(_paths.LogDirectoryPath); + Directory.CreateDirectory(_paths.ConfigurationDirectoryPath); + Directory.CreateDirectory(_paths.CachePath); + } + + private string DatabasePath => Path.Combine(_paths.DataPath, "jellyfin.db"); + + [Fact] + public async Task CheckFirstTimeRunOrMigration_SetUpServerWithoutDatabase_ThrowsWithoutCreatingIt() + { + WriteServerConfiguration(wizardCompleted: true); + var service = CreateService(); + var systemConfiguration = await File.ReadAllBytesAsync(_paths.SystemConfigurationFilePath, TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckFirstTimeRunOrMigration(_paths, new StartupOptions())); + + Assert.Contains("the database does not exist", exception.Message, StringComparison.Ordinal); + Assert.Contains(_paths.SystemConfigurationFilePath, exception.Message, StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(_paths.DataPath, "jellyfin.db*")); + Assert.Equal(systemConfiguration, await File.ReadAllBytesAsync(_paths.SystemConfigurationFilePath, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CheckFirstTimeRunOrMigration_SetUpServerWithEmptyDatabaseFile_Throws() + { + WriteServerConfiguration(wizardCompleted: true); + await File.WriteAllBytesAsync(DatabasePath, [], TestContext.Current.CancellationToken); + var service = CreateService(); + + var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckFirstTimeRunOrMigration(_paths, new StartupOptions())); + + Assert.Contains("the database has no migration history", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CheckFirstTimeRunOrMigration_SetUpServerWithEmptyHistory_Throws() + { + WriteServerConfiguration(wizardCompleted: true); + var service = CreateService(); + await using (var context = await CreateDbContextAsync()) + { + await context.GetService<IHistoryRepository>().CreateIfNotExistsAsync(TestContext.Current.CancellationToken); + } + + var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckFirstTimeRunOrMigration(_paths, new StartupOptions())); + + Assert.Contains("the migration history of the database is empty", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CheckFirstTimeRunOrMigration_SetUpServerWithHistory_Passes() + { + WriteServerConfiguration(wizardCompleted: false); + await CreateService().CheckFirstTimeRunOrMigration(_paths, new StartupOptions()); + var applied = await GetAppliedMigrationIdsAsync(); + WriteServerConfiguration(wizardCompleted: true); + + await CreateService().CheckFirstTimeRunOrMigration(_paths, new StartupOptions()); + + Assert.NotEmpty(applied); + Assert.Equal(applied, await GetAppliedMigrationIdsAsync()); + } + + [Fact] + public async Task CheckFirstTimeRunOrMigration_SeedSystemOnSetUpServerWithoutDatabase_SeedsCodeMigrations() + { + WriteServerConfiguration(wizardCompleted: true); + + await CreateService().CheckFirstTimeRunOrMigration(_paths, new StartupOptions { StartupMode = Configuration.StartupMode.SeedSystem }); + + Assert.NotEmpty(await GetAppliedMigrationIdsAsync()); + } + + [Fact] + public async Task CheckFirstTimeRunOrMigration_MigrateSystemOnSetUpServerWithoutDatabase_Passes() + { + WriteServerConfiguration(wizardCompleted: true); + + await CreateService().CheckFirstTimeRunOrMigration(_paths, new StartupOptions { StartupMode = Configuration.StartupMode.MigrateSystem }); + } + + [Fact] + public async Task CheckFirstTimeRunOrMigration_RestoreArchiveOnSetUpServerWithoutDatabase_Passes() + { + WriteServerConfiguration(wizardCompleted: true); + + await CreateService().CheckFirstTimeRunOrMigration(_paths, new StartupOptions { RestoreArchive = Path.Combine(_root, "backup.zip") }); + } + + public void Dispose() + { + foreach (var serviceProvider in _serviceProviders) + { + serviceProvider.Dispose(); + } + + SqliteConnection.ClearAllPools(); + try + { + Directory.Delete(_root, true); + } + catch (IOException) + { + // Best effort, a locked file must not fail the test. + } + } + + private void WriteServerConfiguration(bool wizardCompleted) + { + new MyXmlSerializer().SerializeToFile(new ServerConfiguration { IsStartupWizardCompleted = wizardCompleted }, _paths.SystemConfigurationFilePath); + } + + private JellyfinMigrationService CreateService() + { + var configurationManager = new ServerConfigurationManager(_paths, NullLoggerFactory.Instance, new MyXmlSerializer()); + configurationManager.AddParts([new DatabaseConfigurationFactory()]); + var serviceProvider = new ServiceCollection() + .AddLogging() + .AddJellyfinDbContext(configurationManager, new ConfigurationBuilder().Build()) + .AddSingleton<IApplicationPaths>(_paths) + .RegisterStartupLogger() + .BuildServiceProvider(); + _serviceProviders.Add(serviceProvider); + + var factory = serviceProvider.GetRequiredService<IDbContextFactory<JellyfinDbContext>>(); + serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>().DbContextFactory = factory; + return ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider); + } + + private async Task<JellyfinDbContext> CreateDbContextAsync() + { + var factory = _serviceProviders[^1].GetRequiredService<IDbContextFactory<JellyfinDbContext>>(); + return await factory.CreateDbContextAsync(TestContext.Current.CancellationToken); + } + + private async Task<string[]> GetAppliedMigrationIdsAsync() + { + await using var context = await CreateDbContextAsync(); + var applied = await context.Database.GetAppliedMigrationsAsync(TestContext.Current.CancellationToken); + return applied.Order(StringComparer.Ordinal).ToArray(); + } +} diff --git a/tests/Jellyfin.Server.Tests/Migrations/MigrateLinkedChildrenStaleItemsTests.cs b/tests/Jellyfin.Server.Tests/Migrations/MigrateLinkedChildrenStaleItemsTests.cs new file mode 100644 index 0000000000..92fc1dca97 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/MigrateLinkedChildrenStaleItemsTests.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +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 MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +/// <summary> +/// Covers which items the stale file cleanup is allowed to delete. Resolving no library locations at +/// all puts every file-backed item outside every location, so acting on that emptied whole libraries. +/// </summary> +public sealed class MigrateLinkedChildrenStaleItemsTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly IApplicationPaths _applicationPaths; + private readonly string _root; + + public MigrateLinkedChildrenStaleItemsTests() + { + _applicationPaths = new Mock<IApplicationPaths>().Object; + _root = Path.Combine(Path.GetTempPath(), "jf-stale-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(_root, "media")); + Directory.CreateDirectory(Path.Combine(_root, "root", "default")); + + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using var context = CreateDbContext(); + context.Database.EnsureCreated(); + } + + private string LibraryDefinitionRoot => Path.Combine(_root, "root", "default"); + + [Fact] + public void Perform_NoLibraryLocationsResolved_KeepsEverything() + { + var itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + SeedItem(itemId, SeedMediaFile("kept.flac")); + + var deleted = Perform(libraryLocations: []); + + Assert.Empty(deleted); + } + + [Fact] + public void Perform_ItemOutsideEveryLocation_IsRemoved() + { + var itemId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + // An empty location counts as inaccessible, which disables the removal on purpose, so the + // library has to hold something for this case to be reachable at all. + SeedMediaFile("anchor.flac"); + SeedItem(itemId, Path.Combine(_root, "removed-media", "gone.flac")); + + var deleted = Perform(libraryLocations: [Path.Combine(_root, "media")]); + + Assert.Equal([itemId], deleted); + } + + [Fact] + public void Perform_ItemInsideLocationWithMissingFile_IsRemoved() + { + var itemId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + SeedMediaFile("anchor.flac"); + SeedItem(itemId, Path.Combine(_root, "media", "deleted.flac")); + + var deleted = Perform(libraryLocations: [Path.Combine(_root, "media")]); + + Assert.Equal([itemId], deleted); + } + + [Fact] + public void Perform_ItemInsideLocationWithFilePresent_IsKept() + { + var itemId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + SeedItem(itemId, SeedMediaFile("present.flac")); + + var deleted = Perform(libraryLocations: [Path.Combine(_root, "media")]); + + Assert.Empty(deleted); + } + + [Fact] + public void Perform_LibraryDefinitionMissing_KeepsItsItems() + { + var libraryId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var itemId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + // The definition folder of "Movies" is gone, so it contributes no locations and everything it + // held reads as unrooted, while "Media" still resolves and makes the run look actionable. + SeedLibrary(libraryId, "Movies", createDefinitionFolder: false); + SeedMediaFile("anchor.flac"); + SeedItem(itemId, Path.Combine(_root, "movies", "kept.mkv"), libraryId); + + var deleted = Perform(libraryLocations: [Path.Combine(_root, "media")]); + + Assert.Empty(deleted); + } + + [Fact] + public void Perform_PathRemovedFromSurvivingLibrary_IsRemoved() + { + var libraryId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var itemId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + + // The library is still there; only one of its media paths went away, which is what this + // branch exists to clean up. + SeedLibrary(libraryId, "Media", createDefinitionFolder: true); + SeedMediaFile("anchor.flac"); + SeedItem(itemId, Path.Combine(_root, "removed-media", "gone.flac"), libraryId); + + var deleted = Perform(libraryLocations: [Path.Combine(_root, "media")]); + + Assert.Equal([itemId], deleted); + } + + public void Dispose() + { + _connection.Dispose(); + if (Directory.Exists(_root)) + { + Directory.Delete(_root, true); + } + } + + private JellyfinDbContext CreateDbContext() => new( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(_applicationPaths, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + + private void SeedLibrary(Guid id, string name, bool createDefinitionFolder) + { + var path = Path.Combine(LibraryDefinitionRoot, name); + if (createDefinitionFolder) + { + Directory.CreateDirectory(path); + } + + using var context = CreateDbContext(); + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = "MediaBrowser.Controller.Entities.CollectionFolder", + Name = name, + Path = path, + IsFolder = true + }); + context.SaveChanges(); + } + + private string SeedMediaFile(string name) + { + var path = Path.Combine(_root, "media", name); + File.WriteAllBytes(path, [0]); + return path; + } + + private void SeedItem(Guid id, string path, Guid? libraryId = null) + { + using var context = CreateDbContext(); + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = typeof(Audio).FullName!, + Name = Path.GetFileNameWithoutExtension(path), + Path = path, + IsFolder = false, + IsVirtualItem = false + }); + + context.SaveChanges(); + + if (libraryId.HasValue) + { + context.Database.ExecuteSql( + $"INSERT INTO AncestorIds (ItemId, ParentItemId) VALUES ({id}, {libraryId.Value})"); + } + } + + private IReadOnlyList<Guid> Perform(string[] libraryLocations, string libraryName = "Media") + { + var deleted = new List<Guid>(); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(l => l.GetVirtualFolders()) + .Returns(libraryLocations.Length == 0 + ? [] + : [new VirtualFolderInfo { Name = libraryName, Locations = libraryLocations }]); + libraryManager + .Setup(l => l.GetItemById(It.IsAny<Guid>())) + .Returns<Guid>(id => new Audio { Id = id }); + libraryManager + .Setup(l => l.DeleteItem(It.IsAny<BaseItem>(), It.IsAny<DeleteOptions>())) + .Callback<BaseItem, DeleteOptions>((item, _) => deleted.Add(item.Id)); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(h => h.ExpandVirtualPath(It.IsAny<string>())).Returns<string>(p => p); + + var appPaths = new Mock<IServerApplicationPaths>(); + appPaths.SetupGet(p => p.DataPath).Returns(Path.Combine(_root, "data")); + appPaths.SetupGet(p => p.InternalMetadataPath).Returns(Path.Combine(_root, "metadata")); + appPaths.SetupGet(p => p.DefaultUserViewsPath).Returns(LibraryDefinitionRoot); + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext); + + new MigrateLinkedChildren( + NullLoggerFactory.Instance, + factory.Object, + libraryManager.Object, + appHost.Object, + appPaths.Object) + .Perform(); + + return deleted; + } +} 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); + } +} |
