diff options
113 files changed, 10939 insertions, 1026 deletions
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index ca7213a690..3aea4abaaa 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -32,13 +32,13 @@ jobs: dotnet-version: '10.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 5b29a66382..7fc22ed001 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -35,7 +35,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # v5.5.10 + uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # v5.5.11 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/.github/workflows/issue-stale.yml b/.github/workflows/issue-stale.yml index 9adac6b995..2ad326e894 100644 --- a/.github/workflows/issue-stale.yml +++ b/.github/workflows/issue-stale.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/.github/workflows/pull-request-stale.yaml b/.github/workflows/pull-request-stale.yaml index f92a9be9d5..2f1a8c7dce 100644 --- a/.github/workflows/pull-request-stale.yaml +++ b/.github/workflows/pull-request-stale.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 14b944f819..d61f1703b2 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -172,6 +172,7 @@ - [whooo](https://github.com/whooo) - [WiiPlayer2](https://github.com/WiiPlayer2) - [WillWill56](https://github.com/WillWill56) + - [WizardOfYendor1](https://github.com/WizardOfYendor1) - [wtayl0r](https://github.com/wtayl0r) - [Wuerfelbecher](https://github.com/Wuerfelbecher) - [Wunax](https://github.com/Wunax) diff --git a/Directory.Packages.props b/Directory.Packages.props index 2e891de7cb..55def8ae1a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,7 +17,7 @@ <PackageVersion Include="Diacritics" Version="4.1.8" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> - <PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.3" /> + <PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.4" /> <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" /> <PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" /> <PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" /> @@ -68,7 +68,7 @@ <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> - <PackageVersion Include="SharpCompress" Version="0.50.1" /> + <PackageVersion Include="SharpCompress" Version="0.50.4" /> <PackageVersion Include="SharpFuzz" Version="2.3.0" /> <PackageVersion Include="SkiaSharp" Version="3.119.4" /> <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" /> diff --git a/Emby.Naming/Video/Format3DParser.cs b/Emby.Naming/Video/Format3DParser.cs index eb5e71d78f..a287a2525b 100644 --- a/Emby.Naming/Video/Format3DParser.cs +++ b/Emby.Naming/Video/Format3DParser.cs @@ -52,13 +52,18 @@ namespace Emby.Naming.Video while (path.Length > 0) { var index = path.IndexOfAny(delimiters); + ReadOnlySpan<char> currentSlice; if (index == -1) { - index = path.Length - 1; + // No delimiter left, the last token is the remainder of the path + currentSlice = path; + path = default; + } + else + { + currentSlice = path[..index]; + path = path[(index + 1)..]; } - - var currentSlice = path[..index]; - path = path[(index + 1)..]; if (!foundPrefix) { diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 0c1c7d3f5b..1a54565863 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -987,8 +987,9 @@ namespace Emby.Server.Implementations /// <inheritdoc/> public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null) { - // If the smartAPI doesn't start with http then treat it as a host or ip. - if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + // If the smartAPI isn't already a complete URL then treat it as a host or ip. + if (hostname.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || hostname.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return hostname.TrimEnd('/'); } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 983ecced02..5db3b80386 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -45,6 +45,7 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Library; using MediaBrowser.Model.Querying; @@ -86,6 +87,7 @@ namespace Emby.Server.Implementations.Library private readonly IPeopleRepository _peopleRepository; private readonly ExtraResolver _extraResolver; private readonly IPathManager _pathManager; + private readonly ILocalizationManager _localization; private readonly FastConcurrentLru<Guid, BaseItem> _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; private readonly IMediaStreamRepository _mediaStreamRepository; @@ -132,6 +134,7 @@ namespace Emby.Server.Implementations.Library /// <param name="peopleRepository">The people repository.</param> /// <param name="pathManager">The path manager.</param> /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> + /// <param name="localization">The localization manager.</param> /// <param name="mediaStreamRepository">The media stream repository.</param> /// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param> public LibraryManager( @@ -157,6 +160,7 @@ namespace Emby.Server.Implementations.Library IPeopleRepository peopleRepository, IPathManager pathManager, DotIgnoreIgnoreRule dotIgnoreIgnoreRule, + ILocalizationManager localization, IMediaStreamRepository mediaStreamRepository, Lazy<IExternalDataManager> externalDataManagerFactory) { @@ -184,6 +188,7 @@ namespace Emby.Server.Implementations.Library _peopleRepository = peopleRepository; _pathManager = pathManager; _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; + _localization = localization; _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -2371,6 +2376,7 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + altVideo.IsInMixedFolder = video.IsInMixedFolder; // ResolveAlternateVersion only sees the alternate's primary file. // If the alternate is itself a stack (e.g. 1080p part1 + part2), // detect its parts from sibling files so its AdditionalParts persist. @@ -2556,6 +2562,8 @@ namespace Emby.Server.Implementations.Library item.DateLastSaved = DateTime.UtcNow; } + ForgetDroppedLocalAlternateVersions(items); + // Resolve and add any local alternate version items that don't exist yet // This ensures they exist in the database when LinkedChildren are processed var allItems = new List<BaseItem>(items); @@ -2584,6 +2592,7 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + altVideo.IsInMixedFolder = video.IsInMixedFolder; // ResolveAlternateVersion only sees the alternate's primary file. // If the alternate is itself a stack (e.g. 1080p part1 + part2), // detect its parts from sibling files so its AdditionalParts persist. @@ -2644,6 +2653,30 @@ namespace Emby.Server.Implementations.Library public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) => UpdateItemsAsync([item], parent, updateReason, cancellationToken); + /// <summary> + /// Forgets the cached local alternate versions of the supplied items that they no longer list. + /// </summary> + /// <param name="items">The items about to be saved.</param> + private void ForgetDroppedLocalAlternateVersions(IReadOnlyList<BaseItem> items) + { + foreach (var video in items.OfType<Video>()) + { + var videoType = video.GetType(); + var keptIds = video.LocalAlternateVersions + .Where(path => !string.IsNullOrEmpty(path)) + .Select(path => GetNewItemId(path, videoType)) + .ToHashSet(); + + foreach (var versionId in GetLocalAlternateVersionIds(video)) + { + if (!keptIds.Contains(versionId)) + { + _cache.TryRemove(versionId, out _); + } + } + } + } + /// <inheritdoc /> public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken) { @@ -3280,9 +3313,11 @@ namespace Emby.Server.Implementations.Library var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath); if (ownerVideoInfo is null) { - yield break; + return []; } + var candidates = new List<ExtraCandidate>(); + var count = filtered.Count; for (var i = 0; i < count; i++) { @@ -3296,35 +3331,50 @@ namespace Emby.Server.Implementations.Library foreach (var file in filesInSubFolderList) { - if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType)) + if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType, out var extraRule)) { continue; } - var extra = GetExtra(file, extraType.Value, subFolderIsMixedFolder); - if (extra is not null) - { - yield return extra; - } + AddCandidate(file, extraType.Value, extraRule, subFolderIsMixedFolder); } } - else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType)) + else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType, out var extraRule)) { - var extra = GetExtra(current, extraType.Value, false); - if (extra is not null) - { - yield return extra; - } + AddCandidate(current, extraType.Value, extraRule, false); + } + } + + var extras = new List<BaseItem>(); + var typeCounters = new Dictionary<ExtraType, int>(); + + // 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 + foreach (var candidate in candidates.OrderBy(c => c.Extra.Path, StringComparer.Ordinal)) + { + var extra = PrepareExtra(candidate); + if (extra is not null) + { + extras.Add(extra); } } - BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder) + return extras; + + void AddCandidate(FileSystemMetadata file, ExtraType extraType, ExtraRule extraRule, bool isInMixedFolder) { var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType)); - if (extra is not Video && extra is not Audio) + if (extra is Video or Audio) { - return null; + candidates.Add(new ExtraCandidate(extra, extraType, extraRule, isInMixedFolder)); } + } + + BaseItem? PrepareExtra(ExtraCandidate candidate) + { + var resolved = candidate.Extra; + var extra = resolved; + var name = GetExtraName(candidate, ownerVideoInfo, typeCounters); // Try to retrieve it from the db. If we don't find it, use the resolved version var itemById = GetItemById(extra.Id); @@ -3333,10 +3383,18 @@ 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) + { + extra.Name = name; + } + // Only update extra type if it is more specific then the currently known extra type - if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown) + if (extra.ExtraType is null or ExtraType.Unknown || candidate.ExtraType != ExtraType.Unknown) { - extra.ExtraType = extraType; + extra.ExtraType = candidate.ExtraType; } // Only return items that are actual extras (have ExtraType set) @@ -3344,7 +3402,7 @@ namespace Emby.Server.Implementations.Library // so that RefreshExtras can detect when they need updating and set ForceSave. if (extra.ExtraType is not null) { - extra.IsInMixedFolder = isInMixedFolder; + extra.IsInMixedFolder = candidate.IsInMixedFolder; return extra; } @@ -3352,6 +3410,57 @@ namespace Emby.Server.Implementations.Library } } + /// <summary> + /// Gets the name to give an extra. + /// </summary> + /// <param name="candidate">The resolved extra.</param> + /// <param name="ownerVideoInfo">The naming info of the owner.</param> + /// <param name="typeCounters">Number of extras named after their type so far, per type.</param> + /// <returns>The name.</returns> + private string GetExtraName(ExtraCandidate candidate, VideoFileInfo ownerVideoInfo, Dictionary<ExtraType, int> typeCounters) + { + var isNamedAfterOwner = candidate.ExtraRule.RuleType switch + { + ExtraRuleType.Filename => true, + ExtraRuleType.Suffix => string.Equals(candidate.Extra.Name, ownerVideoInfo.Name, StringComparison.OrdinalIgnoreCase), + _ => false + }; + + if (!isNamedAfterOwner) + { + return candidate.Extra.Name; + } + + typeCounters.TryGetValue(candidate.ExtraType, out var seen); + typeCounters[candidate.ExtraType] = seen + 1; + + var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType)); + + return seen == 0 + ? typeName + : string.Format( + CultureInfo.InvariantCulture, + _localization.GetServerLocalizedString("NameExtraNumbered"), + typeName, + seen + 1); + } + + private static string GetExtraTypeNameKey(ExtraType extraType) => extraType switch + { + ExtraType.Clip => "NameExtraClip", + ExtraType.Trailer => "NameExtraTrailer", + ExtraType.BehindTheScenes => "NameExtraBehindTheScenes", + ExtraType.DeletedScene => "NameExtraDeletedScene", + ExtraType.Interview => "NameExtraInterview", + ExtraType.Scene => "NameExtraScene", + ExtraType.Sample => "NameExtraSample", + ExtraType.ThemeSong => "NameExtraThemeSong", + ExtraType.ThemeVideo => "NameExtraThemeVideo", + ExtraType.Featurette => "NameExtraFeaturette", + ExtraType.Short => "NameExtraShort", + _ => "NameExtraUnknown" + }; + public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem) { foreach (var map in _configurationManager.Configuration.PathSubstitutions) @@ -3902,5 +4011,7 @@ namespace Emby.Server.Implementations.Library SetTopParentOrAncestorIds(query); return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType); } + + private sealed record ExtraCandidate(BaseItem Extra, ExtraType ExtraType, ExtraRule ExtraRule, bool IsInMixedFolder); } } diff --git a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs index 6ba4a7bce6..a0f75e4ddb 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs @@ -54,12 +54,13 @@ namespace Emby.Server.Implementations.Library.Resolvers _ => _videoResolvers }; - public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "") + public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, [NotNullWhen(true)] out ExtraRule? extraRule, string? libraryRoot = "") { var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot); - if (extraResult.ExtraType is null) + if (extraResult.ExtraType is null || extraResult.Rule is null) { extraType = null; + extraRule = null; return false; } @@ -88,6 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } extraType = extraResult.ExtraType; + extraRule = extraResult.Rule; return isValid; } diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 74c1f69616..d6513fc79c 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Library; @@ -46,6 +47,19 @@ namespace Emby.Server.Implementations.Library.Resolvers }; } + // Anything directly inside the internal playlists folder is a playlist, even when its + // playlist.xml is missing: failing to resolve here makes the library scan treat the + // playlist as deleted from disk and remove it, taking its items with it. + if (args.Parent is PlaylistsFolder) + { + return new Playlist + { + Path = args.Path, + Name = filename, + OpenAccess = true + }; + } + // It's a directory-based playlist if the directory contains a playlist file IEnumerable<string> filePaths; try diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index a5be3f07bd..01f9062734 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -118,7 +118,7 @@ public class SearchManager : ISearchManager var user = _userManager.GetUserById(query.UserId.Value); if (user is not null) { - results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false); + results = await FilterByUserAccessAsync(results, user, query, cancellationToken).ConfigureAwait(false); } } @@ -128,13 +128,14 @@ public class SearchManager : ISearchManager private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync( IReadOnlyList<SearchResult> candidates, User user, + SearchProviderQuery query, CancellationToken cancellationToken) { - // SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates - // TopParentIds for the user's accessible libraries — we call it before assigning ItemIds - // because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty. - var accessFilter = new InternalItemsQuery(user); - _libraryManager.ConfigureUserAccess(accessFilter, user); + // SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds + // for the user's accessible libraries. The candidate ids are applied to the query below + // rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when + // ItemIds is non-empty. + var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager); Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)]; diff --git a/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs new file mode 100644 index 0000000000..6e3f01de13 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs @@ -0,0 +1,38 @@ +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Library.Search; + +/// <summary> +/// Builds the access filter that decides which items a search may return for a user. +/// </summary> +internal static class SearchQueryAccessFilter +{ + /// <summary> + /// Builds an access filter carrying the search's library access and type filters. + /// </summary> + /// <param name="user">The user the search runs for.</param> + /// <param name="query">The search query.</param> + /// <param name="libraryManager">The library manager.</param> + /// <returns>The access filter.</returns> + public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager) + { + // The type filters have to travel with the access filter: a by-name item belongs to no + // library, so it carries no TopParentId to match, and the library filter only knows to + // exempt it when the query says those types are wanted. A search scoped to a parent gets + // no exemption because a by-name item has no parent to descend from either. + var accessFilter = new InternalItemsQuery(user) + { + IncludeItemTypes = query.IncludeItemTypes, + ExcludeItemTypes = query.ExcludeItemTypes, + IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty() + }; + + // ConfigureUserAccess populates TopParentIds for the libraries the user may open. + libraryManager.ConfigureUserAccess(accessFilter, user); + + return accessFilter; + } +} diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs index bc766f1c8c..c4d3b249d5 100644 --- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -114,7 +114,7 @@ public class SqlSearchProvider : IInternalSearchProvider dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes); dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes); dbQuery = ApplyParentFilter(dbQuery, query.ParentId); - dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId); + dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query); // 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 @@ -196,8 +196,9 @@ public class SqlSearchProvider : IInternalSearchProvider private IQueryable<BaseItemEntity> ApplyUserAccessFilter( JellyfinDbContext dbContext, IQueryable<BaseItemEntity> query, - Guid? userId) + SearchProviderQuery searchQuery) { + var userId = searchQuery.UserId; if (!userId.HasValue || userId.Value.IsEmpty()) { return query; @@ -209,8 +210,7 @@ public class SqlSearchProvider : IInternalSearchProvider return query; } - var accessFilter = new InternalItemsQuery(user); - _libraryManager.ConfigureUserAccess(accessFilter, user); + var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager); return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter); } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 17af935562..2dc4f5652b 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -100,7 +100,7 @@ "TaskAudioNormalization": "تطبيع الصوت", "TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.", "TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة", - "TaskDownloadMissingLyricsDescription": "ينزّل الكلمات للأغاني.", + "TaskDownloadMissingLyricsDescription": "تحميل كلمات الأغاني", "TaskExtractMediaSegments": "فحص مقاطع المحتوى", "TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).", "TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم", "CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.", "Original": "فريد", - "LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}" + "LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}", + "NameExtraBehindTheScenes": "خلف المشاهد", + "NameExtraClip": "مقطع", + "NameExtraDeletedScene": "المشهد المحذوف", + "NameExtraInterview": "مقابلة", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "عيّنة", + "NameExtraScene": "مشهد", + "NameExtraShort": "قصير", + "NameExtraThemeSong": "الاغنية السمة", + "NameExtraThemeVideo": "الفيديو السمة", + "NameExtraFeaturette": "فيلم قصير إضافي", + "NameExtraTrailer": "إعلان ترويجي", + "NameExtraUnknown": "إضافي" } diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 6c81726ee6..12076d6c15 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.", "CleanupUserDataTask": "Tasca de neteja de dades d'usuari", "Original": "Original", - "LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}" + "LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}", + "NameExtraBehindTheScenes": "Rere les càmeres", + "NameExtraClip": "Tall", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Migmetratge", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Mostra", + "NameExtraScene": "Escena", + "NameExtraShort": "Curt", + "NameExtraThemeSong": "Tema musical", + "NameExtraThemeVideo": "Vídeo temàtic", + "NameExtraTrailer": "Tràiler", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 28f0e2df97..033002d2b2 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.", "CleanupUserDataTask": "Pročistit uživatelská data", "Original": "Originál", - "LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}" + "LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}", + "NameExtraBehindTheScenes": "Zákulisí", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Vymazaná scéna", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Rozhovor", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ukázka", + "NameExtraScene": "Scéna", + "NameExtraShort": "Krátké", + "NameExtraThemeSong": "Úvodní píseň", + "NameExtraThemeVideo": "Úvodní video", + "NameExtraTrailer": "Upoutávka", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index de56b6fd66..5f5bc1b214 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Brugerdata oprydningsopgave", "CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage.", "LyricDownloadFailureFromForItem": "Sangtekster kunne ikke downloades fra {0} til {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Bag Scenerne", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Slettet Scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Smagsprøve", + "NameExtraScene": "Scene", + "NameExtraShort": "Kort", + "NameExtraThemeSong": "Tema Sang", + "NameExtraThemeVideo": "Tema Video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Ekstra" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 8ac5fdf6fc..1dc454012f 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -108,5 +108,16 @@ "CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten", "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.", "Original": "Original", - "LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}" + "LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraDeletedScene": "Entfernte Szene", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ausschnitt", + "NameExtraScene": "Szene", + "NameExtraShort": "Kurzfilm", + "NameExtraThemeSong": "Titellied", + "NameExtraThemeVideo": "Titelvideo", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 856941c61a..578c85da9d 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -28,6 +28,19 @@ "Movies": "Movies", "Music": "Music", "MusicVideos": "Music Videos", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Deleted Scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sample", + "NameExtraScene": "Scene", + "NameExtraShort": "Short", + "NameExtraThemeSong": "Theme Song", + "NameExtraThemeVideo": "Theme Video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", "NameInstallFailed": "{0} installation failed", "NameSeasonNumber": "Season {0}", "NameSeasonUnknown": "Season Unknown", diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index bccfdd4c19..a30abb9d4e 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.", "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios", "LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index ac489b9e77..f4cf45cb12 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -106,5 +106,20 @@ "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", "TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.", "CleanupUserDataTask": "Tarea de limpieza de los datos del usuario", - "CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días." + "CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días.", + "LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index 563dce8fe6..9e82e0601b 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Tarea de limpieza de datos del usuario", "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.", "Original": "Original", - "LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}" + "LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}", + "NameExtraBehindTheScenes": "Detrás de Cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Reportaje especial", + "NameExtraInterview": "Entrevista", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Tema principal", + "NameExtraThemeVideo": "Vídeo del tema principal", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index 4404354a88..274c60c7bc 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Tarea de limpieza de datos de usuario", "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.", "LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}", - "Original": "Original" + "Original": "Original", + "NameExtraUnknown": "Extra", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler" } diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index e6bf1f25b5..a7afcf5b77 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Puhasta kasutajaandmed", "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud.", "LyricDownloadFailureFromForItem": "Laulusõnade hankimine teenusest {0} loole {1} nurjus", - "Original": "Algne" + "Original": "Algne", + "NameExtraBehindTheScenes": "Kulisside taga", + "NameExtraDeletedScene": "Väljajäetud stseen", + "NameExtraFeaturette": "Lisalõik", + "NameExtraInterview": "Intervjuu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Näidis", + "NameExtraScene": "Stseen", + "NameExtraShort": "Lühifilm", + "NameExtraThemeSong": "Tunnusmeloodia", + "NameExtraThemeVideo": "Tunnusvideo", + "NameExtraTrailer": "Treiler", + "NameExtraUnknown": "Lisamaterjal", + "NameExtraClip": "Videoklipp" } diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index d2bba38cb7..29e007866f 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -51,5 +51,65 @@ "Music": "Tónleikur", "UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}", "HeaderContinueWatching": "Hald áfram at hyggja", - "MusicVideos": "Sjónbandaløg" + "MusicVideos": "Sjónbandaløg", + "TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.", + "TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.", + "TaskOptimizeDatabase": "Albøt dátugrunn", + "NameSeasonNumber": "Sesong {0}", + "NameSeasonUnknown": "Ókend sesong", + "ScheduledTaskFailedWithName": "{0} miseydnaðist", + "Undefined": "Óskilmarkað", + "TasksMaintenanceCategory": "Viðlíkahald", + "TaskCleanLogs": "Reinsa gerðalistaskjáttu", + "UserOnlineFromDevice": "{0} er íbundin frá {1}", + "HeaderNextUp": "Næst á skránni", + "NotificationOptionPluginError": "Brek í ískoytisforriti", + "NotificationOptionInstallationFailed": "Innleggingarbrek", + "NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan", + "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}", + "HomeVideos": "Heimaupptøkur", + "StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.", + "UserOfflineFromDevice": "{0} breyt av á {1}", + "UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt", + "TasksChannelsCategory": "Alnetsrásir", + "TaskCleanActivityLog": "Reinsa virksemisskrá", + "TaskCleanActivityLogDescription": "Strikar skrásetingar eldri enn ásetta aldur.", + "TaskCleanCache": "Reinsa kovaskjáttu", + "TaskCleanCacheDescription": "Strikar kovafílar ið kervið ikki hevur tørv á longur.", + "TaskCleanTranscode": "Reinsa umkotuskjáttu", + "TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir", + "TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir", + "CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.", + "CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur", + "TaskRefreshPeople": "Dagfør persónsupplýsingar", + "TaskRefreshPeopleDescription": "Dagførur metadátur um leikarar og leikstjórar í tínum margmiðlasavni.", + "TaskRefreshChannelsDescription": "Dagførur upplýsingar um alnetsrásir.", + "TaskDownloadMissingSubtitlesDescription": "Leitar á alnótini eftir vantandi undirtekstum grundað á metadátauppsetan.", + "NotificationOptionTaskFailed": "Brek undir fyriskipaðari koyrslu", + "TaskRefreshLibraryDescription": "Skannar títt miðlasavn fyri nýggjum fílum og dagførur metadátur.", + "TaskKeyframeExtractor": "Lyklamyndaúttøka", + "TaskKeyframeExtractorDescription": "Úttekur lyklamyndir frá kykmynda-fílum til tess at byggja nágreiniligari HLS-spælilistar. Koyrslan kann taka langa tíð.", + "TaskOptimizeDatabaseDescription": "Trýstur dátugrunninin saman og loysur tóma goymslu. Koyrslan kann bøta um avrikið, eftir skanning ella aðrar broytingar í savninum ið elva til dátugrunnsbroytingar.", + "TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.", + "TaskRefreshChapterImages": "Kapitlamyndaúttøkur", + "NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað", + "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað", + "NotificationOptionAudioPlayback": "Ljóðspæl byrjað", + "NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað", + "TaskExtractMediaSegments": "Leita eftir margmiðlabrotum", + "TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.", + "NotificationOptionCameraImageUploaded": "Ljósmynd uppsend", + "NameExtraShort": "Stuttfilmur", + "NameExtraThemeSong": "Eyðkennislag", + "NameExtraTrailer": "Forfilmur", + "NameExtraInterview": "Samrøða", + "NameExtraBehindTheScenes": "Aftanfyri leiktjøldini", + "NameExtraClip": "Klipp", + "NameExtraNumbered": "{0} {1}", + "NameExtraFeaturette": "Stuttur heimildarfilmur", + "TaskAudioNormalization": "Ljóðjavnan", + "TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan." } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index e05cce47b0..393af79ab8 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", "LyricDownloadFailureFromForItem": "Le téléchargement des paroles a échoué de {0} pour {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Dans Les Coulisses", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scène supprimée", + "NameExtraFeaturette": "Court-métrage", + "NameExtraInterview": "Entrevue", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Échantillon", + "NameExtraScene": "Scène", + "NameExtraShort": "Court-métrage", + "NameExtraThemeSong": "Chanson thème", + "NameExtraThemeVideo": "Générique", + "NameExtraTrailer": "Bande-annonce", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index ceba1dcb41..858fd9eff6 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", "LyricDownloadFailureFromForItem": "Le téléchargement des paroles à échoué de {0} pour {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Dans Les Coulisses", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scène supprimée", + "NameExtraFeaturette": "Court-métrage", + "NameExtraInterview": "Entrevue", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Échantillon", + "NameExtraScene": "Scène", + "NameExtraShort": "Court-métrage", + "NameExtraThemeSong": "Thème musical", + "NameExtraThemeVideo": "Générique", + "NameExtraTrailer": "Bande-annonce", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json index b889a073a2..a25857e510 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -108,5 +108,18 @@ "Original": "Upprunaleg", "TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt.", "TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir", - "TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins." + "TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins.", + "NameExtraBehindTheScenes": "Bak við tjöldin", + "NameExtraClip": "Brot", + "NameExtraDeletedScene": "Eydd atriði", + "NameExtraFeaturette": "Stutt heimildarmynd", + "NameExtraInterview": "Viðtal", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sýnishorn", + "NameExtraScene": "Sena", + "NameExtraShort": "Stuttmynd", + "NameExtraThemeSong": "Þema lag", + "NameExtraThemeVideo": "Þema myndband", + "NameExtraTrailer": "Stikla", + "NameExtraUnknown": "Aukaefni" } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index f13944e6be..3f4a6e3e54 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -108,5 +108,15 @@ "CleanupUserDataTask": "Task di pulizia dei dati utente", "CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.", "Original": "Originale", - "LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}" + "LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}", + "NameExtraBehindTheScenes": "Dietro le scene", + "NameExtraClip": "Filmato", + "NameExtraDeletedScene": "Scena eliminata", + "NameExtraInterview": "Intervista", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Scena", + "NameExtraSample": "Campione", + "NameExtraShort": "Corto", + "NameExtraThemeSong": "Sigla musicale", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index a210125d34..1f16a90842 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -108,5 +108,8 @@ "CleanupUserDataTask": "사용자 데이터 정리 작업", "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.", "LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다", - "Original": "원본" + "Original": "원본", + "NameExtraClip": "클립", + "NameExtraDeletedScene": "삭제된 장면", + "NameExtraInterview": "인터뷰" } diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index ed26004a43..a5351f299f 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -106,5 +106,19 @@ "TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius trickplay failus pagal bibliotekos nustatymus.", "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius", "CleanupUserDataTask": "Naudotojo duomenų valymo užduotis", - "CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamiausią būseną ir t. t.)." + "CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamiausią būseną ir t. t.).", + "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}", + "NameExtraBehindTheScenes": "Užkulisiuose", + "NameExtraClip": "Klipas", + "NameExtraDeletedScene": "Ištrinta scena", + "NameExtraInterview": "Interviu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Pavyzdys", + "NameExtraScene": "Scena", + "NameExtraThemeSong": "Teminė daina", + "NameExtraThemeVideo": "Teminis vaizdo įrašas", + "NameExtraTrailer": "Anonsas", + "NameExtraUnknown": "Papildomas", + "Original": "Originalus", + "NameExtraFeaturette": "Trumpametražis filmas" } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 9aea3adc22..28ac66e70d 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Opruimtaak gebruikersdata", "Genres": "Genres", "Original": "Oorspronkelijk", - "LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt" + "LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt", + "NameExtraBehindTheScenes": "Achter de schermen", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Geschrapte scène", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Vraaggesprek", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Voorbeeldfragment", + "NameExtraScene": "Scène", + "NameExtraShort": "Korte film", + "NameExtraThemeSong": "Themamuziek", + "NameExtraThemeVideo": "Themavideo", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra inhoud" } diff --git a/Emby.Server.Implementations/Localization/Core/oc.json b/Emby.Server.Implementations/Localization/Core/oc.json index cad5640763..4f573b3c58 100644 --- a/Emby.Server.Implementations/Localization/Core/oc.json +++ b/Emby.Server.Implementations/Localization/Core/oc.json @@ -1,3 +1,27 @@ { - "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}" + "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}", + "Books": "Libres", + "Artists": "Artistas", + "Collections": "Collecciones", + "ChapterNameValue": "Capitol {0}", + "External": "Extèrn", + "Folders": "Dorsièrs", + "Favorites": "Favorits", + "HeaderContinueWatching": "Contunhar de regardar", + "HeaderFavoriteEpisodes": "Episòdis Favorits", + "AuthenticationSucceededWithUserName": "{0} autentificat amb succès", + "HeaderFavoriteShows": "Serias Favoritas", + "HeaderLiveTV": "TV en dirècte", + "HeaderNextUp": "Seguent", + "HearingImpaired": "Amb de deficiéncias auditivas", + "Movies": "Filmes", + "Music": "Musica", + "Latest": "Darrièr", + "Forced": "Forçat", + "Default": "Defaut", + "Genres": "Genres", + "HomeVideos": "Vidèos d'Acuèlh", + "Inherit": "Eiretar", + "LabelIpAddressValue": "Adreça IP: {0}", + "LabelRunningTimeValue": "Temps d'execucion : {0}" } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index c4657bdd6e..71909fd73a 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.", "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika", "Original": "Oryginalny", - "LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}" + "LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}", + "NameExtraBehindTheScenes": "Za kulisami", + "NameExtraClip": "Urywek", + "NameExtraDeletedScene": "Usunięta scena", + "NameExtraFeaturette": "Film średniometrażowy", + "NameExtraInterview": "Wywiad", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Fragment", + "NameExtraScene": "Scena", + "NameExtraShort": "Film krótkometrażowy", + "NameExtraThemeSong": "Czołówka", + "NameExtraThemeVideo": "Wideo wprowadzające", + "NameExtraTrailer": "Zwiastun", + "NameExtraUnknown": "Dodatek" } diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 7ae8857e5d..babacc31a9 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Prečistiť používateľské dáta", "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.", "LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}", - "Original": "Originál" + "Original": "Originál", + "NameExtraBehindTheScenes": "Zo zákulisia", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Vystrihnutá scéna", + "NameExtraFeaturette": "Bonus", + "NameExtraInterview": "Rozhovor", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ukážka", + "NameExtraScene": "Scéna", + "NameExtraShort": "Krátky film", + "NameExtraThemeSong": "Úvodná pieseň", + "NameExtraThemeVideo": "Úvodné video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 7384967122..30c85baaba 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -108,5 +108,13 @@ "CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.", "CleanupUserDataTask": "Uppgift för rensning av användardata", "Original": "Original", - "LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}" + "LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}", + "NameExtraBehindTheScenes": "Bakom kulisserna", + "NameExtraDeletedScene": "Borttagen scen", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Scen", + "NameExtraShort": "Kortfilm", + "NameExtraThemeSong": "Signaturmelodi", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index ccb9d915d1..856740545c 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Завдання очищення даних користувача", "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому.", "Original": "Оригінал", - "LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}" + "LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}", + "NameExtraBehindTheScenes": "За лаштунками", + "NameExtraClip": "Кліп", + "NameExtraDeletedScene": "Видалена сцена", + "NameExtraFeaturette": "Фічуретка", + "NameExtraInterview": "Інтерв’ю", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Приклад", + "NameExtraScene": "Сцена", + "NameExtraShort": "Коротко", + "NameExtraThemeSong": "Тематична пісня", + "NameExtraThemeVideo": "Тематичне вiдео", + "NameExtraTrailer": "Трейлер", + "NameExtraUnknown": "Додатково" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 18418ae0bc..d6e4be01e8 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "用户数据清理任务", "CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。", "LyricDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的歌词", - "Original": "原始" + "Original": "原始", + "NameExtraBehindTheScenes": "幕后花絮", + "NameExtraClip": "片段", + "NameExtraDeletedScene": "删减场景", + "NameExtraFeaturette": "花絮", + "NameExtraInterview": "采访", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "样本", + "NameExtraScene": "场景", + "NameExtraShort": "短片", + "NameExtraThemeSong": "主题曲", + "NameExtraThemeVideo": "主题视频", + "NameExtraTrailer": "预告片", + "NameExtraUnknown": "额外" } diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 409414139c..308faed8cc 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -219,28 +219,15 @@ namespace Emby.Server.Implementations.Playlists var playlist = _libraryManager.GetItemById(playlistId) as Playlist ?? throw new ArgumentException("No Playlist exists with Id " + playlistId); - // Retrieve all the items to be added to the playlist + // Retrieve all the items to be added to the playlist. var newItems = GetPlaylistItems(newItemIds, user, options) .Where(i => i.SupportsAddingToPlaylist); - // Filter out duplicate items - var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet(); - newItems = newItems - .Where(i => !existingIds.Contains(i.Id)) - .Distinct(); - // Create a list of the new linked children to add to the playlist var childrenToAdd = newItems .Select(LinkedChild.Create) .ToList(); - // Log duplicates that have been ignored, if any - int numDuplicates = newItemIds.Count - childrenToAdd.Count; - if (numDuplicates > 0) - { - _logger.LogWarning("Ignored adding {DuplicateCount} duplicate items to playlist {PlaylistName}.", numDuplicates, playlist.Name); - } - // Do nothing else if there are no items to add to the playlist if (childrenToAdd.Count == 0) { diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 2582ed9df0..e81edc82c6 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -223,7 +223,7 @@ namespace Emby.Server.Implementations.Session if (inactive.Count > 0) { - _logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count); + _logger.LogDebug("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count); } foreach (var webSocket in inactive) diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 838f48949d..4aa728b5bf 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -1607,8 +1607,9 @@ public class DynamicHlsController : BaseJellyfinApiController if (state.VideoStream is not null && state.IsOutputVideo) { - // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT - hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont"; + // fMP4 needs frag_discont to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT + // HLS does not use SIDX, and skipping it avoids FFmpeg rewriting open-GOP boundary packet PTS + hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont+skip_sidx"; } segmentFormat = "fmp4" + outputFmp4HeaderArg; diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index ac7c091f85..aa942e7642 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -84,7 +84,7 @@ public class MediaInfoController : BaseJellyfinApiController return NotFound(); } - return await _mediaInfoHelper.GetPlaybackInfo(item, user).ConfigureAwait(false); + return await _mediaInfoHelper.GetPlaybackInfo(item, user, Request).ConfigureAwait(false); } /// <summary> @@ -177,6 +177,7 @@ public class MediaInfoController : BaseJellyfinApiController var info = await _mediaInfoHelper.GetPlaybackInfo( item, user, + Request, mediaSourceId, liveStreamId) .ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index 9ffccaa9e9..51d4081ecf 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -4,6 +4,7 @@ using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; +using Jellyfin.Data; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; @@ -103,6 +104,7 @@ public class PersonsController : BaseJellyfinApiController personTypes, excludePersonTypes) { + AccessFilter = BuildAccessFilter(user), NameContains = searchTerm, NameStartsWith = nameStartsWith, NameLessThan = nameLessThan, @@ -123,6 +125,20 @@ public class PersonsController : BaseJellyfinApiController .ToArray()); } + // People are not owned by a library, so nothing in the Peoples table says which of them a user is + // allowed to see; that only follows from the items they are credited on. + private InternalItemsQuery? BuildAccessFilter(User? user) + { + if (user is null || !user.HasContentRestrictions()) + { + return null; + } + + var accessFilter = new InternalItemsQuery(user) { IncludeOwnedItems = true }; + _libraryManager.ConfigureUserAccess(accessFilter, user); + return accessFilter; + } + /// <summary> /// Get person by name. /// </summary> diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index e53d15acfd..cdbd1ee7aa 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -133,6 +133,7 @@ public class UniversalAudioController : BaseJellyfinApiController var info = await _mediaInfoHelper.GetPlaybackInfo( item, user, + Request, mediaSourceId) .ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index a718035528..ea134a4619 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -40,6 +40,7 @@ public class UserLibraryController : BaseJellyfinApiController private readonly IDtoService _dtoService; private readonly IUserViewManager _userViewManager; private readonly IFileSystem _fileSystem; + private readonly IProviderManager _providerManager; /// <summary> /// Initializes a new instance of the <see cref="UserLibraryController"/> class. @@ -50,13 +51,15 @@ public class UserLibraryController : BaseJellyfinApiController /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> public UserLibraryController( IUserManager userManager, IUserDataManager userDataRepository, ILibraryManager libraryManager, IDtoService dtoService, IUserViewManager userViewManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + IProviderManager providerManager) { _userManager = userManager; _userDataRepository = userDataRepository; @@ -64,6 +67,7 @@ public class UserLibraryController : BaseJellyfinApiController _dtoService = dtoService; _userViewManager = userViewManager; _fileSystem = fileSystem; + _providerManager = providerManager; } /// <summary> @@ -75,7 +79,7 @@ public class UserLibraryController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the item.</returns> [HttpGet("Items/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<BaseItemDto>> GetItem( + public ActionResult<BaseItemDto> GetItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { @@ -94,7 +98,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); + QueueRefreshOnDemandIfNeeded(item); var dtoOptions = new DtoOptions(); @@ -112,7 +116,7 @@ public class UserLibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] - public Task<ActionResult<BaseItemDto>> GetItemLegacy( + public ActionResult<BaseItemDto> GetItemLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => GetItem(userId, itemId); @@ -639,25 +643,28 @@ public class UserLibraryController : BaseJellyfinApiController limit, groupItems); - private async Task RefreshItemOnDemandIfNeeded(BaseItem item) + private void QueueRefreshOnDemandIfNeeded(BaseItem item) { - if (item is Person) + if (item is not Person) { - var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); - var performFullRefresh = !hasMetadata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; + return; + } - if (performFullRefresh) - { - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ForceSave = true - }; - - await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); - } + var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); + if (hasMetadata || (DateTime.UtcNow - item.DateLastRefreshed).TotalDays < 3) + { + return; } + + _providerManager.QueueRefresh( + item.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ForceSave = true + }, + RefreshPriority.High); } /// <summary> diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index ef81235808..c27a18831c 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Entities; @@ -44,6 +45,7 @@ public class MediaInfoHelper private readonly ILogger<MediaInfoHelper> _logger; private readonly INetworkManager _networkManager; private readonly IDeviceManager _deviceManager; + private readonly IServerApplicationHost _appHost; /// <summary> /// Initializes a new instance of the <see cref="MediaInfoHelper"/> class. @@ -56,6 +58,7 @@ public class MediaInfoHelper /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param> /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="appHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param> public MediaInfoHelper( IUserManager userManager, ILibraryManager libraryManager, @@ -64,7 +67,8 @@ public class MediaInfoHelper IServerConfigurationManager serverConfigurationManager, ILogger<MediaInfoHelper> logger, INetworkManager networkManager, - IDeviceManager deviceManager) + IDeviceManager deviceManager, + IServerApplicationHost appHost) { _userManager = userManager; _libraryManager = libraryManager; @@ -74,6 +78,7 @@ public class MediaInfoHelper _logger = logger; _networkManager = networkManager; _deviceManager = deviceManager; + _appHost = appHost; } /// <summary> @@ -81,40 +86,20 @@ public class MediaInfoHelper /// </summary> /// <param name="item">The item.</param> /// <param name="user">The user.</param> + /// <param name="request">The current <see cref="HttpRequest"/>.</param> /// <param name="mediaSourceId">Media source id.</param> /// <param name="liveStreamId">Live stream id.</param> /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns> public async Task<PlaybackInfoResponse> GetPlaybackInfo( BaseItem item, User? user, + HttpRequest request, string? mediaSourceId = null, string? liveStreamId = null) { var result = new PlaybackInfoResponse(); - MediaSourceInfo[] mediaSources; - if (string.IsNullOrWhiteSpace(liveStreamId)) - { - // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? - var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(mediaSourceId)) - { - mediaSources = mediaSourcesList.ToArray(); - } - else - { - mediaSources = mediaSourcesList - .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - } - else - { - var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); - - mediaSources = new[] { mediaSource }; - } + var mediaSources = await ResolvePlaybackMediaSources(item, user, mediaSourceId, liveStreamId).ConfigureAwait(false); if (mediaSources.Length == 0) { @@ -136,6 +121,11 @@ public class MediaInfoHelper mediaSourcesClone[i].DefaultAudioIndexSource = mediaSources[i].DefaultAudioIndexSource; } + foreach (var mediaSource in mediaSourcesClone) + { + RewritePublishedLiveStreamPath(mediaSource, request); + } + result.MediaSources = mediaSourcesClone; } @@ -145,6 +135,28 @@ public class MediaInfoHelper return result; } + private async Task<MediaSourceInfo[]> ResolvePlaybackMediaSources(BaseItem item, User? user, string? mediaSourceId, string? liveStreamId) + { + if (!string.IsNullOrWhiteSpace(liveStreamId)) + { + var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); + + return new[] { mediaSource }; + } + + // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? + var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(mediaSourceId)) + { + return mediaSourcesList.ToArray(); + } + + return mediaSourcesList + .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + /// <summary> /// SetDeviceSpecificData. /// </summary> @@ -415,6 +427,8 @@ public class MediaInfoHelper { var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); + RewritePublishedLiveStreamPath(result.MediaSource, httpContext.Request); + var profile = request.DeviceProfile; if (profile is null) { @@ -524,4 +538,90 @@ public class MediaInfoHelper return maxBitrate; } + + /// <summary> + /// Rewrites a Live TV media source's <see cref="MediaSourceInfo.Path"/> to the request-appropriate published + /// URL when it points at a Jellyfin-hosted live stream buffer, so response copies never leak server-local + /// addresses. Only opened live streams are eligible. The shared instance held by + /// <see cref="IMediaSourceManager"/> is never touched by this method. + /// </summary> + /// <param name="mediaSource">The media source clone to rewrite in place.</param> + /// <param name="request">The current <see cref="HttpRequest"/>.</param> + private void RewritePublishedLiveStreamPath(MediaSourceInfo mediaSource, HttpRequest request) + { + // Opened live streams always carry a LiveStreamId; this excludes pre-open and plugin/remote sources. + if (string.IsNullOrEmpty(mediaSource.LiveStreamId)) + { + return; + } + + var baseUrl = _serverConfigurationManager.GetNetworkConfiguration().BaseUrl; + var publishedPath = GetPublishedLiveStreamPath(_appHost.GetSmartApiUrl(request), mediaSource.Path, mediaSource.Protocol, baseUrl); + + if (publishedPath is not null) + { + mediaSource.Path = publishedPath; + return; + } + + if (mediaSource.Path is not null && mediaSource.Path.Contains("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Not rewriting live stream path for media source {MediaSourceId}: the local path did not resolve under the request's smart API URL/BaseUrl", mediaSource.Id); + } + } + + /// <summary> + /// Resolves a Jellyfin-hosted Live TV buffer path to its request-appropriate published equivalent. + /// Returns null when the path isn't a Jellyfin-hosted <c>/LiveTv/LiveStreamFiles/</c> HTTP URL. + /// </summary> + /// <param name="smartApiUrl">The request-appropriate base URL, as returned by <see cref="IServerApplicationHost.GetSmartApiUrl(HttpRequest)"/>.</param> + /// <param name="localPath">The media source's local (LAN-access) path, as built from <see cref="IServerApplicationHost.GetApiUrlForLocalAccess"/>.</param> + /// <param name="protocol">The media source's protocol.</param> + /// <param name="baseUrl">The server's configured BaseUrl, if any.</param> + /// <returns>The published path, or null if the local path should be left unchanged.</returns> + internal static string? GetPublishedLiveStreamPath( + string smartApiUrl, + string? localPath, + MediaProtocol protocol, + string baseUrl) + { + if (protocol != MediaProtocol.Http + || !Uri.TryCreate(localPath, UriKind.Absolute, out var localUri)) + { + return null; + } + + var relativePath = localUri.PathAndQuery; + if (!string.IsNullOrEmpty(baseUrl)) + { + var basePrefix = baseUrl + "/"; + if (!relativePath.StartsWith(basePrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + relativePath = relativePath[baseUrl.Length..]; + } + + if (!relativePath.StartsWith("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var prefix = smartApiUrl.TrimEnd('/'); + if (!string.IsNullOrEmpty(baseUrl)) + { + var includesBaseUrl = Uri.TryCreate(prefix, UriKind.Absolute, out var publishedUri) + && Uri.UnescapeDataString(publishedUri.AbsolutePath) + .TrimEnd('/') + .EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase); + + if (!includesBaseUrl) + { + prefix += baseUrl; + } + } + + return prefix + relativePath; + } } diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 6a6aac1327..60c5bb2ef6 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -198,11 +198,6 @@ public static class StreamingHelpers state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream, state.OutputAudioChannels) ?? 0; } - if (outputAudioCodec.StartsWith("pcm_", StringComparison.Ordinal)) - { - containerInternal = ".pcm"; - } - if (state.VideoRequest is not null) { state.OutputVideoCodec = state.Request.VideoCodec; diff --git a/Jellyfin.Data/UserEntityExtensions.cs b/Jellyfin.Data/UserEntityExtensions.cs index 0fc8d3cd25..c6468daa2e 100644 --- a/Jellyfin.Data/UserEntityExtensions.cs +++ b/Jellyfin.Data/UserEntityExtensions.cs @@ -163,6 +163,23 @@ public static class UserEntityExtensions } /// <summary> + /// Checks whether any library, parental rating or tag rule keeps content from this user. + /// </summary> + /// <param name="entity">The user to check.</param> + /// <returns><c>True</c> if some content in the library is hidden from this user.</returns> + public static bool HasContentRestrictions(this User entity) + { + ArgumentNullException.ThrowIfNull(entity); + + return !entity.HasPermission(PermissionKind.EnableAllFolders) + || entity.GetPreference(PreferenceKind.BlockedMediaFolders).Length > 0 + || entity.MaxParentalRatingScore.HasValue + || entity.GetPreference(PreferenceKind.BlockedTags).Length > 0 + || entity.GetPreference(PreferenceKind.AllowedTags).Length > 0 + || entity.GetPreference(PreferenceKind.BlockUnratedItems).Length > 0; + } + + /// <summary> /// Initializes the default permissions for a user. Should only be called on user creation. /// </summary> /// <param name="entity">The entity to update.</param> diff --git a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs index d70ac672f2..0f166fc6e0 100644 --- a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs @@ -40,6 +40,19 @@ public static class ExpressionExtensions } /// <summary> + /// Negates a predicate. + /// </summary> + /// <typeparam name="T">The predicate parameter type.</typeparam> + /// <param name="predicate">The predicate expression to negate.</param> + /// <returns>A new expression representing the negation of the input predicate.</returns> + public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + + return Expression.Lambda<Func<T, bool>>(Expression.Not(predicate.Body), predicate.Parameters); + } + + /// <summary> /// Combines two predicates into a single predicate using a logical AND operation. /// </summary> /// <typeparam name="T">The predicate parameter type.</typeparam> diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs index c64e6ac068..c2cb644c59 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs @@ -134,6 +134,21 @@ public static class BaseItemMapper if (dto is Video video) { video.PrimaryVersionId = entity.PrimaryVersionId; + + // The LinkedChildren table is the source of truth for version links + if (entity.LinkedChildEntities is not null) + { + video.LinkedAlternateVersions = entity.LinkedChildEntities + // LocalAlternateVersion links belong to Video.LocalAlternateVersions, not here + .Where(e => e.ChildType == Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion) + .OrderBy(e => e.SortOrder) + .Select(e => new LinkedChild + { + ItemId = e.ChildId, + Type = (MediaBrowser.Controller.Entities.LinkedChildType)e.ChildType + }) + .ToArray(); + } } if (dto is IHasSeries hasSeriesName) @@ -183,7 +198,7 @@ public static class BaseItemMapper if (dto is Folder folder) { folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); - if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0) + if (entity.LinkedChildEntities is not null) { folder.LinkedChildren = entity.LinkedChildEntities .OrderBy(e => e.SortOrder) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index a4de9feb05..05ff720ddf 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -35,11 +35,40 @@ public sealed partial class BaseItemRepository { dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyNavigations(dbQuery, filter); return dbQuery; } + /// <summary> + /// Trims an ordered query down to the AdjacentTo item and its immediate neighbours. + /// </summary> + private IQueryable<BaseItemEntity> ApplyAdjacencyFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) + { + if (filter.AdjacentTo.IsNullOrEmpty()) + { + return dbQuery; + } + + // Adjacency is relative to the result set and the order the query asked for, so the ids have + // to be read back in that order. + var orderedIds = dbQuery.Select(e => e.Id).ToList(); + var index = orderedIds.IndexOf(filter.AdjacentTo.Value); + if (index < 0) + { + // The item isn't part of this result set, so it has no neighbours in it either. + return dbQuery.Take(0); + } + + var start = Math.Max(index - 1, 0); + var adjacentIds = orderedIds.GetRange(start, Math.Min(index + 2, orderedIds.Count) - start); + + var adjacentQuery = context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => adjacentIds.Contains(e.Id)); + + return ApplyOrder(adjacentQuery, filter, context); + } + private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { if (filter.Limit.HasValue || filter.StartIndex.HasValue) @@ -244,8 +273,8 @@ public sealed partial class BaseItemRepository dbQuery = dbQuery.Include(e => e.Images); } - // Include LinkedChildEntities for container types and videos that use them - // (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions). + // Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist, + // CollectionFolder for manual linking; every video type for alternate versions). // When IncludeItemTypes is empty (any type may be returned), always include them to ensure // LinkedChildren are loaded before items are saved back, preventing accidental deletion. var linkedChildTypes = new[] @@ -254,7 +283,10 @@ public sealed partial class BaseItemRepository BaseItemKind.Playlist, BaseItemKind.CollectionFolder, BaseItemKind.Video, - BaseItemKind.Movie + BaseItemKind.Movie, + BaseItemKind.Episode, + BaseItemKind.MusicVideo, + BaseItemKind.Trailer }; if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains)) { @@ -390,12 +422,24 @@ public sealed partial class BaseItemRepository var baseQuery = context.BaseItems .AsNoTracking() - .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + .Where(b => allDescendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); return ApplyAccessFiltering(context, baseQuery, filter); } /// <summary> + /// Checks whether the user restricts access to items by parental rating or tags. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns><c>true</c> if the query carries parental restrictions.</returns> + private static bool RequiresParentalRestrictions(InternalItemsQuery filter) + => filter.IncludeInheritedTags.Length > 0 + || filter.ExcludeInheritedTags.Length > 0 + || filter.MaxParentalRating is not null + || filter.BlockUnratedItems.Length > 0; + + /// <summary> /// Applies user access filtering to a query. /// Includes TopParentIds, parental rating, and tag filtering. /// </summary> @@ -405,13 +449,127 @@ public sealed partial class BaseItemRepository IQueryable<BaseItemEntity> baseQuery, InternalItemsQuery filter) { - // Apply TopParentIds filtering (library folder access) - if (filter.TopParentIds.Length > 0) + baseQuery = ApplyTopParentFiltering(context, baseQuery, filter); + + 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. + if (!filter.IncludeOwnedItems) + { + baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); + } + + return baseQuery; + } + + /// <summary> + /// Restricts a query to the libraries the user may open, exempting requested by-name items. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The query to filter.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The filtered query.</returns> + private IQueryable<BaseItemEntity> ApplyTopParentFiltering( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter) + { + var queryTopParentIds = filter.TopParentIds; + if (queryTopParentIds.Length == 0) + { + return baseQuery; + } + + var exemptedItemByNameTypes = GetExemptedItemByNameTypes(filter); + if (exemptedItemByNameTypes.Count == 0) + { + return baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value); + } + + baseQuery = baseQuery.Where(e => exemptedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value)); + if (filter.UserHasContentRestrictions) + { + baseQuery = ApplyItemByNameAccessFiltering(baseQuery, context, filter, exemptedItemByNameTypes, queryTopParentIds); + } + + return baseQuery; + } + + /// <summary> + /// Returns the by-name types a query asks for, which carry no TopParentId to filter on. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The type names exempt from library filtering.</returns> + private List<string> GetExemptedItemByNameTypes(InternalItemsQuery filter) + { + var includedItemByNameTypes = GetItemByNameTypesInQuery(filter); + if ((filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0) + { + return includedItemByNameTypes; + } + + return _itemByNameKinds.Where(filter.IncludeItemTypes.Contains).Select(e => _itemTypeLookup.BaseItemKindNames[e]!).ToList(); + } + + /// <summary> + /// Keeps a by-name row only when at least one item behind its name is reachable for the user. + /// </summary> + /// <param name="baseQuery">The query to filter.</param> + /// <param name="context">The database context.</param> + /// <param name="filter">The query filter.</param> + /// <param name="itemByNameTypes">The exempted by-name type names.</param> + /// <param name="topParentIds">The libraries the user may open.</param> + /// <returns>The filtered query.</returns> + private IQueryable<BaseItemEntity> ApplyItemByNameAccessFiltering( + IQueryable<BaseItemEntity> baseQuery, + JellyfinDbContext context, + InternalItemsQuery filter, + IReadOnlyList<string> itemByNameTypes, + Guid[] topParentIds) + { + // IncludeOwnedItems: a credit on an alternate version of a reachable movie still counts. + var accessibleItems = ApplyAccessFiltering( + context, + context.BaseItems.AsNoTracking(), + new InternalItemsQuery(filter.User) { TopParentIds = topParentIds, IncludeOwnedItems = true }); + + var personType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + if (itemByNameTypes.Contains(personType)) + { + baseQuery = baseQuery.Where(e => e.Type != personType + || context.Peoples.Any(p => p.Name == e.Name + && context.PeopleBaseItemMap.Any(m => m.PeopleId == p.Id && accessibleItems.Any(i => i.Id == m.ItemId)))); + } + + foreach (var (kind, valueTypes) in _itemByNameValueTypes) { - var topParentIds = filter.TopParentIds; - baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value)); + var typeName = _itemTypeLookup.BaseItemKindNames[kind]; + if (!itemByNameTypes.Contains(typeName)) + { + continue; + } + + baseQuery = baseQuery.Where(e => e.Type != typeName + || context.ItemValues.Any(v => valueTypes.Contains(v.Type) && v.CleanValue == e.CleanName + && context.ItemValuesMap.Any(m => m.ItemValueId == v.ItemValueId && accessibleItems.Any(i => i.Id == m.ItemId)))); } + return baseQuery; + } + + /// <summary> + /// Applies the user's parental rating and tag restrictions to a query. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The query to filter.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The filtered query.</returns> + private IQueryable<BaseItemEntity> ApplyParentalRestrictions( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter) + { // Apply parental rating filtering if (filter.MaxParentalRating is not null) { @@ -462,13 +620,6 @@ public sealed partial class BaseItemRepository || e.Type == personTypeName); } - // Exclude alternate versions (have PrimaryVersionId set) and 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)); - } - return baseQuery; } @@ -503,62 +654,31 @@ public sealed partial class BaseItemRepository } /// <inheritdoc /> - public IQueryable<Guid> GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable<Guid> folderIds, User user) + public IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(JellyfinDbContext context, User user, bool includeOwnedItems = false) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(folderIds); ArgumentNullException.ThrowIfNull(user); - var filter = new InternalItemsQuery(user); - var userId = user.Id; - var leafItems = context.BaseItems .AsNoTracking() - .Where(b => !b.IsFolder && !b.IsVirtualItem); - leafItems = ApplyAccessFiltering(context, leafItems, filter); + .Where(DescendantQueryHelper.IsCountableLeaf); - var playedLeafItems = leafItems - .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); - - var ancestorLeaves = context.AncestorIds - .Where(a => folderIds.Contains(a.ParentItemId)) - .Join( - playedLeafItems, - a => a.ItemId, - b => b.Id, - (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played }); - - var linkedLeaves = context.LinkedChildren - .Where(lc => folderIds.Contains(lc.ParentId)) - .Join( - playedLeafItems, - lc => lc.ChildId, - b => b.Id, - (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played }); + return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems }); + } - var linkedFolderLeaves = context.LinkedChildren - .Where(lc => folderIds.Contains(lc.ParentId)) - .Join( - context.BaseItems.Where(b => b.IsFolder), - lc => lc.ChildId, - b => b.Id, - (lc, b) => new { lc.ParentId, FolderChildId = b.Id }) - .Join( - context.AncestorIds, - x => x.FolderChildId, - a => a.ParentItemId, - (x, a) => new { x.ParentId, DescendantId = a.ItemId }) - .Join( - playedLeafItems, - x => x.DescendantId, - b => b.Id, - (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); - - return ancestorLeaves - .Union(linkedLeaves) - .Union(linkedFolderLeaves) - .GroupBy(x => x.FolderId) - .Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count()) - .Select(g => g.Key); + /// <inheritdoc /> + public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(descendants); + + // Descendants are reachable through the ancestor chain and - for BoxSets and Playlists - as + // linked children, which can themselves be folders contributing their own descendants. + // Every step is a correlated index seek, so only the rows the outer query keeps are visited + // and a folder is left as soon as its first matching descendant is found. + return e => context.AncestorIds.Any(a => a.ParentItemId == e.Id && descendants.Any(d => d.Id == a.ItemId)) + || context.LinkedChildren.Any(lc => lc.ParentId == e.Id + && (descendants.Any(d => d.Id == lc.ChildId) + || context.AncestorIds.Any(a => a.ParentItemId == lc.ChildId && descendants.Any(d => d.Id == a.ItemId)))); } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index 1ff8d8f863..c7acf72043 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -49,6 +49,7 @@ public sealed partial class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); if (filter.EnableTotalRecordCount) { @@ -75,6 +76,7 @@ public sealed partial class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random); @@ -167,7 +169,10 @@ public sealed partial class BaseItemRepository .Where(album => albumIdsWithMatchingTrack.Contains(album.Id)); } - var orderedAlbums = topAlbumsQuery + // The album is what gets returned, and neither branch above reads it through the + // user's filters, so its own parental restrictions have to be applied here: a + // matching track does not make an album the user may not see visible. + var orderedAlbums = ApplyParentalRestrictions(context, topAlbumsQuery, filter) .OrderByDescending(album => album.DateCreated) .ThenByDescending(album => album.Id); @@ -420,6 +425,40 @@ public sealed partial class BaseItemRepository seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId)); } + // Step 5b: A container is what gets returned, so it has to pass the user's access + // filters on its own - a matching episode does not make a Season or Series the user + // may not see visible. Containers that don't pass are replaced by their episode. + if (RequiresParentalRestrictions(filter) && entitiesToFetch.Count > 0) + { + var allowedContainerIds = ApplyParentalRestrictions( + context, + context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)), + filter) + .Select(e => e.Id) + .ToHashSet(); + + for (var i = 0; i < seriesResults.Count; i++) + { + var (seasonId, seriesId, maxDate, mostRecentEpisodeId) = seriesResults[i]; + if (seasonId.HasValue && !allowedContainerIds.Contains(seasonId.Value)) + { + seasonId = null; + } + + if (seriesId.HasValue && !allowedContainerIds.Contains(seriesId.Value)) + { + seriesId = null; + } + + if (seasonId is null && seriesId is null) + { + entitiesToFetch.Add(mostRecentEpisodeId); + } + + seriesResults[i] = (seasonId, seriesId, maxDate, mostRecentEpisodeId); + } + } + // Step 6: Fetch the Season/Series entities we decided to return var entities = entitiesToFetch.Count > 0 ? ApplyNavigations( diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 47f8a40b9c..8c0a39fe4c 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -31,6 +31,10 @@ public sealed partial class BaseItemRepository private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant(); private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant(); + // A fresh expression per access: EF rejects a query tree that reuses one lambda parameter + // instance across several lambdas, and this filter is combined into a tree more than once. + private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder; + /// <inheritdoc /> public IQueryable<BaseItemEntity> TranslateQuery( IQueryable<BaseItemEntity> baseQuery, @@ -466,97 +470,50 @@ public sealed partial class BaseItemRepository if (filter.IsPlayed.HasValue) { - var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); - var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet); + var userId = filter.User!.Id; - if (hasSeries || hasBoxSet) - { - var userId = filter.User!.Id; - var isPlayed = filter.IsPlayed.Value; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet]; - - // Series: played = at least one episode AND all episodes played; unplayed = otherwise. - IQueryable<Guid> playedSeriesIds = hasSeries - ? context.BaseItems - .AsNoTracking() - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) - .GroupBy(e => e.SeriesId!.Value) - .Where(g => !g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) - .Select(g => g.Key) - : Enumerable.Empty<Guid>().AsQueryable(); - - // BoxSet: played = all children played. - IQueryable<Guid> playedBoxSetIds = hasBoxSet - ? GetFullyPlayedFolderIdsQuery( - context, - baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id), - filter.User!) - : Enumerable.Empty<Guid>().AsQueryable(); - - // Non-folder items: check UserData directly - var playedItemIds = context.UserData - .Where(ud => ud.UserId == userId && ud.Played) - .Select(ud => ud.ItemId); - - if (isPlayed) - { - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && playedSeriesIds.Contains(e.Id)) - || (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id)) - || (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id))); - } - else - { - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && !playedSeriesIds.Contains(e.Id)) - || (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id)) - || (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id))); - } - } - else - { - var playedItemIds = context.UserData - .Where(ud => ud.UserId == filter.User!.Id && ud.Played) - .Select(ud => ud.ItemId); - var isPlayedItem = filter.IsPlayed.Value; - baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id) == isPlayedItem); - } + // Leaf items carry their own played state. + var playedItemIds = context.UserData + .Where(ud => ud.UserId == userId && ud.Played) + .Select(ud => ud.ItemId); + + // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no + // descendant is left unplayed, matching what the DTO reports for them. This has to key off + // the item itself rather than off the requested item types: tag and collection listings mix + // folders and leaf items in a single query. + var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!) + .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + + var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not()) + .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id))); + + baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not()); } if (filter.IsResumable.HasValue) { - var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); var userId = filter.User!.Id; var isResumable = filter.IsResumable.Value; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; // In-progress user data rows; alternate versions track their own progress. var inProgress = context.UserData .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); - IQueryable<Guid>? resumableSeriesIds = null; - if (hasSeries) - { - // Aggregate per series in a single GROUP BY pass, instead of three full scans. - var seriesEpisodeStats = context.BaseItems - .AsNoTracking() - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) - .GroupBy(e => e.SeriesId!.Value) - .Select(g => new - { - SeriesId = g.Key, - HasInProgress = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)), - HasPlayed = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)), - HasUnplayed = g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) - }); - - // A series is resumable if it has an in-progress episode, - // or if it has both played and unplayed episodes (partially watched). - resumableSeriesIds = seriesEpisodeStats - .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed)) - .Select(s => s.SeriesId); - } + // Series and Seasons are resumable when a descendant is in progress, or when they hold both + // played and unplayed descendants (partially watched). Alternate versions keep their own + // progress, so they count towards the in-progress check but not towards the played/unplayed one. + var leafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!); + var inProgressLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!, includeOwnedItems: true) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)); + + // Every other folder kind is a container rather than one continuous piece of media + var resumableFolderTypes = _resumableFolderKinds + .Select(kind => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(kind)) + .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)))))); if (isResumable) { @@ -564,18 +521,15 @@ public sealed partial class BaseItemRepository // Match each version on its own progress rather than coalescing onto the primary. var inProgressIds = inProgress.Select(ud => ud.ItemId); - baseQuery = hasSeries - ? baseQuery.Where(e => - (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id)) - || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id))) - : baseQuery.Where(e => inProgressIds.Contains(e.Id)); + baseQuery = baseQuery.Where(folderIsResumableFilter + .Or(IsFolderFilter.Not().And(e => inProgressIds.Contains(e.Id)))); // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker. // Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate, // which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by // the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row. // Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those. - baseQuery = baseQuery.Where(e => e.Type == seriesTypeName + baseQuery = baseQuery.Where(e => e.IsFolder || (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id)) || !context.BaseItems .Where(s => s.Id != e.Id @@ -594,11 +548,8 @@ public sealed partial class BaseItemRepository var resumableMovieIds = inProgress .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); - baseQuery = hasSeries - ? baseQuery.Where(e => - (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id)) - || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id))) - : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id)); + baseQuery = baseQuery.Where(IsFolderFilter.And(folderIsResumableFilter.Not()) + .Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id)))); } } @@ -1044,21 +995,7 @@ public sealed partial class BaseItemRepository : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName)); } - var queryTopParentIds = filter.TopParentIds; - - if (queryTopParentIds.Length > 0) - { - var includedItemByNameTypes = GetItemByNameTypesInQuery(filter); - var enableItemsByName = (filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0; - if (enableItemsByName && includedItemByNameTypes.Count > 0) - { - baseQuery = baseQuery.Where(e => includedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value)); - } - else - { - baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value); - } - } + baseQuery = ApplyTopParentFiltering(context, baseQuery, filter); if (filter.AncestorIds.Length > 0) { @@ -1167,16 +1104,23 @@ public sealed partial class BaseItemRepository : baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder); } + // An extra is owned by the single version of an item it is named after, so an extra on any + // version counts for the item itself + IQueryable<Guid> WithPrimaryVersions(IQueryable<Guid> ownerIds) + => ownerIds.Concat(context.BaseItems + .Where(version => version.PrimaryVersionId != null && ownerIds.Contains(version.Id)) + .Select(version => version.PrimaryVersionId!.Value)); + if (filter.HasSpecialFeature.HasValue) { - var itemsWithExtras = context.BaseItems + var itemsWithExtras = WithPrimaryVersions(context.BaseItems .Where(extra => extra.OwnerId != null && extra.ExtraType != null && extra.ExtraType != BaseItemExtraType.Unknown && extra.ExtraType != BaseItemExtraType.Trailer && extra.ExtraType != BaseItemExtraType.ThemeSong && extra.ExtraType != BaseItemExtraType.ThemeVideo) - .Select(extra => extra.OwnerId!.Value) + .Select(extra => extra.OwnerId!.Value)) .Distinct(); Expression<Func<BaseItemEntity, bool>> hasExtras = e => itemsWithExtras.Contains(e.Id); @@ -1188,9 +1132,9 @@ public sealed partial class BaseItemRepository if (filter.HasTrailer.HasValue) { - var trailerOwnerIds = context.BaseItems + var trailerOwnerIds = WithPrimaryVersions(context.BaseItems .Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null) - .Select(extra => extra.OwnerId!.Value); + .Select(extra => extra.OwnerId!.Value)); Expression<Func<BaseItemEntity, bool>> hasTrailer = e => trailerOwnerIds.Contains(e.Id); @@ -1201,9 +1145,9 @@ public sealed partial class BaseItemRepository if (filter.HasThemeSong.HasValue) { - var themeSongOwnerIds = context.BaseItems + var themeSongOwnerIds = WithPrimaryVersions(context.BaseItems .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null) - .Select(extra => extra.OwnerId!.Value); + .Select(extra => extra.OwnerId!.Value)); Expression<Func<BaseItemEntity, bool>> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id); @@ -1214,9 +1158,9 @@ public sealed partial class BaseItemRepository if (filter.HasThemeVideo.HasValue) { - var themeVideoOwnerIds = context.BaseItems + var themeVideoOwnerIds = WithPrimaryVersions(context.BaseItems .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null) - .Select(extra => extra.OwnerId!.Value); + .Select(extra => extra.OwnerId!.Value)); Expression<Func<BaseItemEntity, bool>> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id); @@ -1243,33 +1187,6 @@ public sealed partial class BaseItemRepository } } - if (filter.AdjacentTo.HasValue && !filter.AdjacentTo.Value.IsEmpty()) - { - var adjacentToId = filter.AdjacentTo.Value; - var targetItem = context.BaseItems.Where(e => e.Id == adjacentToId).Select(e => new { e.SortName, e.Id }).FirstOrDefault(); - if (targetItem is not null) - { - var targetSortName = targetItem.SortName ?? string.Empty; - - // Fetch both prev and next adjacent items in a single query using Concat (UNION ALL). - var adjacentIds = context.BaseItems - .Where(e => string.Compare(e.SortName, targetSortName) < 0) - .OrderByDescending(e => e.SortName) - .Select(e => e.Id) - .Take(1) - .Concat( - context.BaseItems - .Where(e => string.Compare(e.SortName, targetSortName) > 0) - .OrderBy(e => e.SortName) - .Select(e => e.Id) - .Take(1)) - .ToList(); - - adjacentIds.Add(adjacentToId); - baseQuery = baseQuery.Where(e => adjacentIds.Contains(e.Id)); - } - } - return baseQuery; } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 57041276b7..1d2aa21853 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -46,6 +46,31 @@ public sealed partial class BaseItemRepository private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios]; private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre]; + private static readonly BaseItemKind[] _itemByNameKinds = + [ + BaseItemKind.Person, + BaseItemKind.Genre, + BaseItemKind.MusicGenre, + BaseItemKind.MusicArtist, + BaseItemKind.Studio + ]; + + private static readonly (BaseItemKind Kind, IReadOnlyList<ItemValueType> ValueTypes)[] _itemByNameValueTypes = + [ + (BaseItemKind.Genre, _getGenreValueTypes), + (BaseItemKind.MusicGenre, _getGenreValueTypes), + (BaseItemKind.MusicArtist, _getAllArtistsValueTypes), + (BaseItemKind.Studio, _getStudiosValueTypes) + ]; + + // The only folder kinds whose children form a single viewing sequence, so playback progress on a + // child rolls up to them. Every other folder kind is a container that cannot be resumed. + private static readonly BaseItemKind[] _resumableFolderKinds = + [ + BaseItemKind.Series, + BaseItemKind.Season + ]; + /// <summary> /// Initializes a new instance of the <see cref="BaseItemRepository"/> class. /// </summary> diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index 604db9f839..fd683fb57e 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -141,32 +141,32 @@ public class ItemCountService : IItemCountService switch (kind) { case BaseItemKind.Person: - baseQuery = context.PeopleBaseItemMap + baseQuery = ItemsById(context, context.PeopleBaseItemMap .AsNoTracking() .Where(m => m.People.Name == item.Name) - .Select(m => m.Item); + .Select(m => m.ItemId)); break; case BaseItemKind.MusicArtist: - baseQuery = context.ItemValuesMap + baseQuery = ItemsById(context, context.ItemValuesMap .AsNoTracking() .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName && (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist)) - .Select(ivm => ivm.Item); + .Select(ivm => ivm.ItemId)); break; case BaseItemKind.Genre: case BaseItemKind.MusicGenre: - baseQuery = context.ItemValuesMap + baseQuery = ItemsById(context, context.ItemValuesMap .AsNoTracking() .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName && ivm.ItemValue.Type == ItemValueType.Genre) - .Select(ivm => ivm.Item); + .Select(ivm => ivm.ItemId)); break; case BaseItemKind.Studio: - baseQuery = context.ItemValuesMap + baseQuery = ItemsById(context, context.ItemValuesMap .AsNoTracking() .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName && ivm.ItemValue.Type == ItemValueType.Studios) - .Select(ivm => ivm.Item); + .Select(ivm => ivm.ItemId)); break; case BaseItemKind.Year: if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) @@ -254,6 +254,9 @@ public class ItemCountService : IItemCountService return result; } + private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds) + => context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id)); + /// <inheritdoc/> public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) { @@ -293,7 +296,8 @@ public class ItemCountService : IItemCountService var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId); var baseQuery = dbContext.BaseItems - .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + .Where(b => allDescendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); @@ -354,7 +358,7 @@ public class ItemCountService : IItemCountService var userId = user.Id; var leafItems = dbContext.BaseItems - .Where(b => !b.IsFolder && !b.IsVirtualItem); + .Where(DescendantQueryHelper.IsCountableLeaf); leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter); var playedLeafItems = leafItems diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index b10f7c527e..827c766449 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -428,106 +428,144 @@ public class ItemPersistenceService : IItemPersistenceService foreach (var item in tuples) { - if (item.Item is Folder folder) + // A container that was never hydrated cannot be used to rewrite its links: its empty + // array means "unknown", so clearing the stored rows would silently empty the item. + if (item.Item is Folder { LinkedChildrenLoaded: false }) { - var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>(); - if (folder.LinkedChildren.Length > 0) + continue; + } + + if (item.Item is Folder or Video + && allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks) + && existingLinks.Count > 0) + { + // A video only owns its alternate version links; any other link on that parent is + // written by the folder branch below and must survive. + var staleLinks = item.Item is Folder + ? existingLinks + : existingLinks + .Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAlternateVersion) + .ToList(); + + if (staleLinks.Count > 0) { + context.LinkedChildren.RemoveRange(staleLinks); + } + } + } + + context.SaveChanges(); + + // A LinkedChild's ItemId is only a cache. + var cachedChildIds = tuples + .Select(t => t.Item) + .OfType<Folder>() + .Where(f => f.LinkedChildrenLoaded) + .SelectMany(f => f.LinkedChildren) + .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) + .Select(lc => lc.ItemId!.Value) + .Distinct() + .ToList(); + + var knownChildIds = cachedChildIds.Count > 0 + ? context.BaseItems + .WhereOneOrMany(cachedChildIds, e => e.Id) + .Select(e => e.Id) + .ToHashSet() + : []; + + foreach (var item in tuples) + { + if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0) + { #pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data - var pathsToResolve = folder.LinkedChildren - .Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path)) - .Select(lc => lc.Path) - .Distinct() - .ToList(); + var pathsToResolve = folder.LinkedChildren + .Where(lc => !string.IsNullOrEmpty(lc.Path) + && (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value))) + .Select(lc => lc.Path) + .Distinct() + .ToList(); - var pathToIdMap = pathsToResolve.Count > 0 - ? context.BaseItems - .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) - .Select(e => new { e.Path, e.Id }) - .GroupBy(e => e.Path!) - .ToDictionary(g => g.Key, g => g.First().Id) - : []; + var pathToIdMap = pathsToResolve.Count > 0 + ? context.BaseItems + .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) + .Select(e => new { e.Path, e.Id }) + .GroupBy(e => e.Path!) + .ToDictionary(g => g.Key, g => g.First().Id) + : []; - var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); - foreach (var linkedChild in folder.LinkedChildren) + var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); + foreach (var linkedChild in folder.LinkedChildren) + { + var childItemId = linkedChild.ItemId; + if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value)) { - var childItemId = linkedChild.ItemId; - if (!childItemId.HasValue || childItemId.Value.IsEmpty()) + if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) { - if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) - { - childItemId = resolvedId; - } + childItemId = resolvedId; } -#pragma warning restore CS0618 - - if (childItemId.HasValue && !childItemId.Value.IsEmpty()) + else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty()) { - resolvedChildren.Add((linkedChild, childItemId.Value)); + childItemId = libraryItemId; } } +#pragma warning restore CS0618 + if (childItemId.HasValue && !childItemId.Value.IsEmpty()) + { + resolvedChildren.Add((linkedChild, childItemId.Value)); + } + } + + // Playlists may legitimately contain the same item multiple times (e.g. a song repeated + // in an .m3u file). Every other container type keeps a single entry per child. + var isPlaylist = folder is Playlist; + if (!isPlaylist) + { resolvedChildren = resolvedChildren .GroupBy(c => c.ChildId) .Select(g => g.Last()) .ToList(); + } - var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList(); - var existingChildIds = childIdsToCheck.Count > 0 - ? context.BaseItems - .Where(e => childIdsToCheck.Contains(e.Id)) - .Select(e => e.Id) - .ToHashSet() - : []; - - var isPlaylist = folder is Playlist; - var sortOrder = 0; - foreach (var (linkedChild, childId) in resolvedChildren) - { - if (!existingChildIds.Contains(childId)) - { - _logger.LogWarning( - "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database", - item.Item.Name, - item.Item.Id, - childId); - continue; - } - - var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); - if (existingLink is null) - { - context.LinkedChildren.Add(new LinkedChildEntity() - { - ParentId = item.Item.Id, - ChildId = childId, - ChildType = (DbLinkedChildType)linkedChild.Type, - SortOrder = isPlaylist ? sortOrder : null - }); - } - else - { - existingLink.SortOrder = isPlaylist ? sortOrder : null; - existingLink.ChildType = (DbLinkedChildType)linkedChild.Type; - existingLinkedChildren.Remove(existingLink); - } + var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList(); + var existingChildIds = childIdsToCheck.Count > 0 + ? context.BaseItems + .Where(e => childIdsToCheck.Contains(e.Id)) + .Select(e => e.Id) + .ToHashSet() + : []; - sortOrder++; + var sortOrder = 0; + foreach (var (linkedChild, childId) in resolvedChildren) + { + if (!existingChildIds.Contains(childId)) + { +#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics + _logger.LogWarning( + "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {ChildPath}) does not exist in database", + item.Item.Name, + item.Item.Id, + childId, + linkedChild.Path ?? "unknown"); +#pragma warning restore CS0618 + continue; } - } - if (existingLinkedChildren.Count > 0) - { - context.LinkedChildren.RemoveRange(existingLinkedChildren); + context.LinkedChildren.Add(new LinkedChildEntity() + { + ParentId = item.Item.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)linkedChild.Type, + SortOrder = sortOrder + }); + + sortOrder++; } } if (item.Item is Video video) { - var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedChildEntity>()) - .Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3) - .ToList(); - var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>(); if (video.LocalAlternateVersions.Length > 0) @@ -577,7 +615,7 @@ public class ItemPersistenceService : IItemPersistenceService .ToHashSet() : []; - int sortOrder = 0; + var sortOrder = 0; foreach (var (childId, childType) in newLinkedChildren) { if (!existingChildIds.Contains(childId)) @@ -590,36 +628,27 @@ public class ItemPersistenceService : IItemPersistenceService continue; } - var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); - if (existingLink is null) + context.LinkedChildren.Add(new LinkedChildEntity { - context.LinkedChildren.Add(new LinkedChildEntity - { - ParentId = video.Id, - ChildId = childId, - ChildType = (DbLinkedChildType)childType, - SortOrder = sortOrder - }); - } - else - { - existingLink.ChildType = (DbLinkedChildType)childType; - existingLink.SortOrder = sortOrder; - existingLinkedChildren.Remove(existingLink); - } + ParentId = video.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)childType, + SortOrder = sortOrder + }); sortOrder++; } - if (existingLinkedChildren.Count > 0) + // A previously-linked LocalAlternateVersion that is no longer present becomes orphaned; + var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id); + if (previousLinkedChildren is { Count: > 0 }) { - var orphanedLocalVersionIds = existingLinkedChildren - .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion) + var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet(); + var orphanedLocalVersionIds = previousLinkedChildren + .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.ChildId)) .Select(e => e.ChildId) .ToList(); - context.LinkedChildren.RemoveRange(existingLinkedChildren); - if (orphanedLocalVersionIds.Count > 0) { var orphanedItems = context.BaseItems diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 5e5ce320a5..5f1d9bf87a 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -159,12 +159,16 @@ public class LinkedChildrenService : ILinkedChildrenService if (existingLink is null) { + var nextSortOrder = (context.LinkedChildren + .Where(lc => lc.ParentId == parentId) + .Max(lc => (int?)lc.SortOrder) ?? -1) + 1; + context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity { ParentId = parentId, ChildId = childId, ChildType = dbChildType, - SortOrder = null + SortOrder = nextSortOrder }); } else diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index 25ad81ec6c..00b10e44a9 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -68,7 +68,7 @@ public static class OrderMapper (ItemSortBy.DateCreated, _) => e => e.DateCreated, (ItemSortBy.PremiereDate, _) => e => e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null), (ItemSortBy.StartDate, _) => e => e.StartDate, - (ItemSortBy.Name, _) => e => e.SortName, + (ItemSortBy.Name, _) => e => e.CleanName, (ItemSortBy.CommunityRating, _) => e => e.CommunityRating, (ItemSortBy.ProductionYear, _) => e => e.ProductionYear, (ItemSortBy.CriticRating, _) => e => e.CriticRating, diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 9611c5c13a..05c8bffd66 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -21,10 +21,11 @@ namespace Jellyfin.Server.Implementations.Item; /// </summary> /// <param name="dbProvider">Efcore Factory.</param> /// <param name="itemTypeLookup">Items lookup service.</param> +/// <param name="queryHelpers">Shared item query helpers.</param> /// <remarks> /// Initializes a new instance of the <see cref="PeopleRepository"/> class. /// </remarks> -public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository +public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) : IPeopleRepository { private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider; @@ -33,12 +34,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I { using var context = _dbProvider.CreateDbContext(); var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter); + int? distinctNameCount = null; // Include PeopleBaseItemMap if (!filter.ItemId.IsEmpty()) { dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId)) - .OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder) + .OrderBy(e => e.BaseItems!.Where(m => m.ItemId == filter.ItemId).Min(m => m.ListOrder)) .ThenBy(e => e.PersonType) .ThenBy(e => e.Name); } @@ -46,17 +48,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I { // The Peoples table has one row per (Name, PersonType), so the same person can // appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per - // name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the - // grouping key so case-only duplicates collapse together. - var representativeIds = dbQuery - .GroupBy(e => e.Name.ToLower()) - .Select(g => g.Min(e => e.Id)); - dbQuery = context.Peoples.AsNoTracking() - .Where(p => representativeIds.Contains(p.Id)) - .OrderBy(e => e.Name); + // name so /Persons doesn't return the same BaseItem id repeatedly, keeping the + // lowest id per lowercased name so case-only duplicates collapse together. + var candidates = dbQuery; + dbQuery = candidates + .Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id)) + .OrderBy(e => e.Name.ToLower()); + + if (filter.EnableTotalRecordCount) + { + distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count(); + } + } + + var count = 0; + if (filter.EnableTotalRecordCount) + { + count = distinctNameCount ?? dbQuery.Count(); } - var count = dbQuery.Count(); if (filter.StartIndex.HasValue && filter.StartIndex > 0) { dbQuery = dbQuery.Skip(filter.StartIndex.Value); @@ -71,7 +81,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I { StartIndex = filter.StartIndex ?? 0, TotalRecordCount = count, - Items = dbQuery.AsEnumerable().Select(Map).ToArray(), + Items = dbQuery.AsEnumerable().SelectMany(MapCredits).ToArray(), }; } @@ -107,9 +117,17 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I person.Role = person.Role?.Trim() ?? string.Empty; } - // multiple metadata providers can provide the _same_ person; dedupe case-insensitively. - people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray(); - var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray(); + // Project the values every comparison below needs once, so neither the case folding nor the + // enum formatting is repeated per candidate. + var credits = people.Select(e => (Person: e, LoweredName: e.Name.ToLowerInvariant(), PersonType: e.Type.ToString(), LoweredRole: e.Role.ToLowerInvariant())); + + // multiple metadata providers can provide the _same_ credit; dedupe case-insensitively. + // The role is part of the key because one person can hold several credits of the same type + // on an item, e.g. a Writer credited for both the Novel and the Screenplay. + 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(); using var transaction = context.Database.BeginTransaction(); @@ -122,23 +140,44 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I .Select(f => f.item) .ToArray(); - var toAdd = people - .Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString())) - .Select(Map); + var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet(); + + var toAdd = distinctPersons + .Where(e => !existingPersonKeys.Contains((e.LoweredName, e.PersonType))) + .Select(e => Map(e.Person)) + .ToArray(); context.Peoples.AddRange(toAdd); context.SaveChanges(); - var personsEntities = toAdd.Concat(existingPersons).ToArray(); + // The Peoples table can hold case-only duplicates, so keep the first match per key just as + // the previous First() lookup did. + var personsEntities = new Dictionary<(string LoweredName, string PersonType), People>(); + foreach (var entity in toAdd.Concat(existingPersons)) + { + 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) + { + existingMapsByCredit.TryAdd((map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), map); + } var listOrder = 0; - foreach (var person in people) + foreach (var credit in distinctCredits) { - var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString()); - var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); - if (existingMap is null) + var entityPerson = personsEntities[(credit.LoweredName, credit.PersonType)]; + if (existingMapsByCredit.TryGetValue((credit.LoweredName, credit.PersonType, credit.LoweredRole), out var existingMap)) + { + // Update the order for existing mappings + existingMap.ListOrder = listOrder; + existingMap.SortOrder = credit.Person.SortOrder; + // person mapping already exists so remove from list + existingMaps.Remove(existingMap); + } + else { context.PeopleBaseItemMap.Add(new PeopleBaseItemMap() { @@ -147,18 +186,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I People = null!, PeopleId = entityPerson.Id, ListOrder = listOrder, - SortOrder = person.SortOrder, - Role = person.Role + SortOrder = credit.Person.SortOrder, + Role = credit.Person.Role }); } - else - { - // Update the order for existing mappings - existingMap.ListOrder = listOrder; - existingMap.SortOrder = person.SortOrder; - // person mapping already exists so remove from list - existingMaps.Remove(existingMap); - } listOrder++; } @@ -205,9 +236,19 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I return result; } - private PersonInfo Map(People people) + private IEnumerable<PersonInfo> MapCredits(People people) + { + var mappings = people.BaseItems; + if (mappings is null || mappings.Count == 0) + { + return [Map(people, null)]; + } + + return mappings.OrderBy(m => m.ListOrder).Select(m => Map(people, m)); + } + + private PersonInfo Map(People people, PeopleBaseItemMap? mapping) { - var mapping = people.BaseItems?.FirstOrDefault(); var personInfo = new PersonInfo() { Id = people.Id, @@ -240,13 +281,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (filter.User is not null && filter.IsFavorite.HasValue) { var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; - var oldQuery = query; + var userId = filter.User.Id; + var isFavorite = filter.IsFavorite.Value; + var favoriteItemIds = context.UserData + .Where(u => u.UserId.Equals(userId) && u.IsFavorite == isFavorite) + .Select(u => u.ItemId); - query = context.UserData - .Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id)) - .Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person) - .Distinct() - .AsNoTracking(); + var favoriteNames = context.BaseItems + .Where(b => b.Type == personType && favoriteItemIds.Contains(b.Id)) + .Select(b => b.Name); + + query = query.Where(e => favoriteNames.Contains(e.Name)); + } + + if (filter.AccessFilter is not null) + { + // Keep only people credited on at least one item the user can see. + var accessibleItems = queryHelpers.ApplyAccessFiltering(context, context.BaseItems.AsNoTracking(), filter.AccessFilter); + query = query.Where(e => context.PeopleBaseItemMap + .Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId))); } if (!filter.ItemId.IsEmpty()) diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index 13c7895f83..0989ce84ba 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -87,8 +87,9 @@ public static class StorageHelper /// </summary> private static string ResolvePath(string path) { - var parts = path.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); - var current = Path.DirectorySeparatorChar.ToString(); + var root = Path.GetPathRoot(path) ?? Path.DirectorySeparatorChar.ToString(); + var parts = path.Substring(root.Length).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + var current = root; foreach (var part in parts) { current = Path.Combine(current, part); diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index a10be76e05..beafc3916f 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -193,10 +193,15 @@ internal class JellyfinMigrationService { var historyRepository = dbContext.GetService<IHistoryRepository>(); var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>(); - (string Key, IInternalMigration Migration)[] migrations = []; + var completedMigrations = 0; + string? lastMigrationKey = null; - do - { // migrations may alter the migration state. Reevaluate the applicable migrations after every stage ran until there are no more to apply. + while (true) + { + // A single migration can change which migrations still apply: IMigrator.MigrateAsync treats its argument as the + // state to end up in, so it reverts everything applied after it, and a reverted migration can take code migrations + // with it (AddNormalizedUsername.Down drops the UpdateNormalizedUsername history row). Anything computed before + // that point is stale, so only ever run the next migration and then work out the pending set again. var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); var pendingCodeMigrations = migrationStage .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) @@ -212,73 +217,86 @@ internal class JellyfinMigrationService } (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; - logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); - migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); + if (pendingMigrations.Length == 0) + { + break; + } - var migrationIndex = 0; - foreach (var item in migrations) + if (completedMigrations == 0) { - // Surface generic "Running migration X of Y" progress in the always-visible startup UI header. - SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length)); - var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); - try - { - migrationLogger.LogInformation("Perform migration {Name}", item.Key); - await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false); - migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key); - } - catch (Exception ex) - { - migrationLogger.LogCritical("Error: {Error}", ex.Message); - migrationLogger.LogError(ex, "Migration {Name} failed", item.Key); + logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage); + } + + var item = pendingMigrations.OrderBy(e => e.Key, StringComparer.Ordinal).First(); + if (string.Equals(item.Key, lastMigrationKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Migration {item.Key} ran but did not record itself as applied and would repeat indefinitely."); + } + + lastMigrationKey = item.Key; + + // Surface generic "Running migration X of Y" progress in the always-visible startup UI header. + SetupServer.ReportActivity(StartupActivity.Migration(completedMigrations + 1, completedMigrations + pendingMigrations.Length)); + var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); + try + { + migrationLogger.LogInformation("Perform migration {Name}", item.Key); + await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false); + migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key); + } + catch (Exception ex) + { + migrationLogger.LogCritical("Error: {Error}", ex.Message); + migrationLogger.LogError(ex, "Migration {Name} failed", item.Key); - if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null) + if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null) + { + if (_backupKey.LibraryDb is not null) { - if (_backupKey.LibraryDb is not null) + migrationLogger.LogInformation("Attempt to rollback librarydb."); + try { - migrationLogger.LogInformation("Attempt to rollback librarydb."); - try - { - var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); - File.Move(_backupKey.LibraryDb, libraryDbPath, true); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb); - } + var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); + File.Move(_backupKey.LibraryDb, libraryDbPath, true); } - - if (_backupKey.JellyfinDb is not null) + catch (Exception inner) { - migrationLogger.LogInformation("Attempt to rollback JellyfinDb."); - try - { - await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb); - } + migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb); } + } - if (_backupKey.FullBackup is not null) + if (_backupKey.JellyfinDb is not null) + { + migrationLogger.LogInformation("Attempt to rollback JellyfinDb."); + try { - migrationLogger.LogInformation("Attempt to rollback from backup."); - try - { - await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path); - } + await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception inner) + { + migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb); } } - throw; + if (_backupKey.FullBackup is not null) + { + migrationLogger.LogInformation("Attempt to rollback from backup."); + try + { + await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false); + } + catch (Exception inner) + { + migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path); + } + } } + + throw; } - } while (migrations.Length != 0); + + completedMigrations++; + } } } diff --git a/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs deleted file mode 100644 index 1545ebdc8e..0000000000 --- a/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Playlists; - -namespace Jellyfin.Server.Migrations.Routines; - -/// <summary> -/// Remove duplicate playlist entries. -/// </summary> -#pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2025-04-20T19:00:00", nameof(RemoveDuplicatePlaylistChildren), "96C156A2-7A13-4B3B-A8B8-FB80C94D20C0")] -internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine -#pragma warning restore CS0618 // Type or member is obsolete -{ - private readonly ILibraryManager _libraryManager; - private readonly IPlaylistManager _playlistManager; - - public RemoveDuplicatePlaylistChildren( - ILibraryManager libraryManager, - IPlaylistManager playlistManager) - { - _libraryManager = libraryManager; - _playlistManager = playlistManager; - } - - /// <inheritdoc/> - public void Perform() - { - var playlists = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Playlist] - }) - .Cast<Playlist>() - .Where(p => !p.OpenAccess || !p.OwnerUserId.Equals(Guid.Empty)) - .ToArray(); - - if (playlists.Length > 0) - { - foreach (var playlist in playlists) - { - var linkedChildren = playlist.LinkedChildren; - if (linkedChildren.Length > 0) - { - var newLinkedChildren = linkedChildren - .Where(c => c.ItemId is null || c.ItemId.Value.Equals(Guid.Empty)) - .Concat(linkedChildren - .Where(c => c.ItemId.HasValue && !c.ItemId.Value.Equals(Guid.Empty)) - .DistinctBy(c => c.ItemId)) - .ToArray(); - playlist.LinkedChildren = newLinkedChildren; - playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); - _playlistManager.SavePlaylistFile(playlist); - } - } - } - } -} diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs index c433c1d043..4a6e74c229 100644 --- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs @@ -63,7 +63,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var itemsWithData = context.BaseItems .Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type))) - .Select(b => new { b.Id, b.Data, b.Type }) + .Select(b => new { b.Id, b.Data, b.Type, b.Path, b.IsFolder }) .ToList(); _logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count); @@ -74,6 +74,15 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .GroupBy(b => b.Path!) .ToDictionary(g => g.Key, g => g.First().Id); + // Needed to tell a stale cached ItemId apart from one that still points at a real item. + var allItemIds = context.BaseItems.Select(b => b.Id).ToHashSet(); + + var playlistParentIds = itemsWithData + .Where(b => b.Type == "MediaBrowser.Controller.Playlists.Playlist") + .Select(b => b.Id) + .ToHashSet(); + + var droppedChildren = 0; var linkedChildrenToAdd = new List<LinkedChildEntity>(); var processedCount = 0; const int progressLogStep = 1000; @@ -100,7 +109,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // Handle Video alternate versions if (isVideo) { - ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd); + ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, allItemIds, linkedChildrenToAdd); } // Handle LinkedChildren (for containers and other items) @@ -110,46 +119,22 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine continue; } - var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist"; + // Legacy entries may hold a path relative to the container that holds them, so the + // container's own location has to be a real path, not a virtual one. + var itemPath = item.Path is null ? null : _appHost.ExpandVirtualPath(item.Path); + var containingFolderPath = item.IsFolder ? itemPath : Path.GetDirectoryName(itemPath); var sortOrder = 0; foreach (var childElement in linkedChildrenElement.EnumerateArray()) { - Guid? childId = null; - if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null) - { - var itemIdStr = itemIdProp.GetString(); - if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId)) - { - childId = parsedId; - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (childElement.TryGetProperty("Path", out var pathProp)) - { - var path = pathProp.GetString(); - if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId)) - { - childId = resolvedId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (childElement.TryGetProperty("LibraryItemId", out var libIdProp)) - { - var libIdStr = libIdProp.GetString(); - if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId)) - { - childId = parsedLibId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) + var childId = ResolveChildId(childElement, containingFolderPath, pathToIdMap, allItemIds); + if (!childId.HasValue) { + droppedChildren++; + _logger.LogWarning( + "Dropping unresolvable LinkedChild of {ParentId}: ItemId {ItemId}, path {ChildPath}", + item.Id, + GetStringProperty(childElement, "ItemId") ?? "none", + GetStringProperty(childElement, "Path") ?? "none"); continue; } @@ -175,7 +160,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine ParentId = item.Id, ChildId = childId.Value, ChildType = childType, - SortOrder = isPlaylist ? sortOrder : null + SortOrder = sortOrder }); sortOrder++; @@ -197,23 +182,37 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(lc => new { lc.ParentId, lc.ChildId }) .ToHashSet(); + // A playlist may list the same child more than once, so it cannot be keyed by + // (ParentId, ChildId): skip a playlist wholesale if it already has rows instead, which + // keeps the routine re-runnable without collapsing repeated entries. + var populatedParentIds = context.LinkedChildren + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + var toInsert = linkedChildrenToAdd - .Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) + .Where(lc => playlistParentIds.Contains(lc.ParentId) + ? !populatedParentIds.Contains(lc.ParentId) + : !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) .ToList(); if (toInsert.Count > 0) { - // Deduplicate by composite key (ParentId, ChildId) + // Every container type other than a playlist keeps a single entry per child. // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other - toInsert = toInsert - .OrderBy(lc => lc.ChildType switch - { - LinkedChildType.LocalAlternateVersion => 0, - LinkedChildType.LinkedAlternateVersion => 1, - _ => 2 - }) - .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) - .ToList(); + toInsert = + [ + .. toInsert.Where(lc => playlistParentIds.Contains(lc.ParentId)), + .. toInsert + .Where(lc => !playlistParentIds.Contains(lc.ParentId)) + .OrderBy(lc => lc.ChildType switch + { + LinkedChildType.LocalAlternateVersion => 0, + LinkedChildType.LinkedAlternateVersion => 1, + _ => 2 + }) + .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) + ]; var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList(); var existingChildIds = context.BaseItems @@ -267,7 +266,10 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("No LinkedChildren data found to migrate."); } - _logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount); + _logger.LogInformation( + "LinkedChildren migration completed. Processed {Count} items, dropped {DroppedCount} unresolvable children.", + processedCount, + droppedChildren); CleanupWrongTypeAlternateVersions(context); CleanupOrphanedAlternateVersionBaseItems(context); @@ -418,6 +420,12 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var internalMetadataPath = _appPaths.InternalMetadataPath; + // 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; + var skippedUnrootedItems = 0; + var staleIds = new List<Guid>(); foreach (var item in itemsWithPaths) { @@ -436,6 +444,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // Directory check covers BDMV/DVD items whose Path points to a folder if (!File.Exists(path) && !Directory.Exists(path)) { + _logger.LogDebug("Removing item {ItemId}: file {Path} no longer exists.", item.Id, path); staleIds.Add(item.Id); } } @@ -443,12 +452,28 @@ 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) - staleIds.Add(item.Id); + if (canRemoveUnrootedItems) + { + _logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, path); + staleIds.Add(item.Id); + } + else + { + skippedUnrootedItems++; + } } // Otherwise: item is under an inaccessible location — skip (storage may be offline) } + if (skippedUnrootedItems > 0) + { + _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) { _logger.LogInformation("No stale items found."); @@ -518,18 +543,86 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine orphanedLinkedChildren.AddRange(orphanedByParent); } - // Remove all orphaned records - var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList(); + // Remove all orphaned records. Both queries can return the same row, and a playlist may hold + // several rows for one child, so the position is what identifies an entry here. + var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.SortOrder }).ToList(); context.LinkedChildren.RemoveRange(distinctOrphaned); context.SaveChanges(); _logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count); } + /// <summary> + /// Resolves the item a legacy LinkedChild entry points at. + /// </summary> + private static Guid? ResolveChildId( + JsonElement childElement, + string? containingFolderPath, + Dictionary<string, Guid> pathToIdMap, + HashSet<Guid> allItemIds) + { + // Pre-12 data only cached ItemId and re-resolved it from the path whenever the cached value + // went stale (BaseItem.GetLinkedChild in 10.x). An id that no longer exists must therefore + // fall through to the path, or the entry is lost even though its file is still in the library. + if (TryGetGuidProperty(childElement, "ItemId", out var itemId) && allItemIds.Contains(itemId)) + { + return itemId; + } + + var path = GetStringProperty(childElement, "Path"); + if (!string.IsNullOrEmpty(path)) + { + if (pathToIdMap.TryGetValue(path, out var idByPath)) + { + return idByPath; + } + + // 10.x resolved entries relative to the container that holds them. + if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(containingFolderPath)) + { + string? absolutePath = null; + try + { + absolutePath = Path.GetFullPath(Path.Combine(containingFolderPath, path)); + } + catch (ArgumentException) + { + // Malformed path, nothing to resolve. + } + + if (absolutePath is not null && pathToIdMap.TryGetValue(absolutePath, out var idByAbsolutePath)) + { + return idByAbsolutePath; + } + } + } + + if (TryGetGuidProperty(childElement, "LibraryItemId", out var libraryItemId) && allItemIds.Contains(libraryItemId)) + { + return libraryItemId; + } + + return null; + } + + private static string? GetStringProperty(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static bool TryGetGuidProperty(JsonElement element, string propertyName, out Guid value) + { + value = Guid.Empty; + var raw = GetStringProperty(element, propertyName); + + return !string.IsNullOrEmpty(raw) && Guid.TryParse(raw, out value) && !value.IsEmpty(); + } + private void ProcessVideoAlternateVersions( JsonElement root, Guid parentId, Dictionary<string, Guid> pathToIdMap, + HashSet<Guid> allItemIds, List<LinkedChildEntity> linkedChildrenToAdd) { int sortOrder = 0; @@ -582,45 +675,8 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray()) { - Guid? childId = null; - - // Try to get ItemId - if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null) - { - var itemIdStr = itemIdProp.GetString(); - if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId)) - { - childId = parsedId; - } - } - - // Try to get from Path if ItemId not available - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (linkedChildElement.TryGetProperty("Path", out var pathProp)) - { - var path = pathProp.GetString(); - if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId)) - { - childId = resolvedId; - } - } - } - - // Try LibraryItemId as fallback - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp)) - { - var libIdStr = libIdProp.GetString(); - if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId)) - { - childId = parsedLibId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) + var childId = ResolveChildId(linkedChildElement, null, pathToIdMap, allItemIds); + if (!childId.HasValue) { _logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId); continue; diff --git a/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs new file mode 100644 index 0000000000..16ac6cb5e5 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Restores playlist entries from playlist.xml for playlists that lost all of their children. +/// </summary> +[JellyfinMigration("2026-07-29T12:00:00", nameof(RestorePlaylistChildrenFromMetadata))] +internal class RestorePlaylistChildrenFromMetadata : IDatabaseMigrationRoutine +{ + private const string PlaylistTypeName = "MediaBrowser.Controller.Playlists.Playlist"; + private const string PlaylistFileName = "playlist.xml"; + + private readonly ILogger<RestorePlaylistChildrenFromMetadata> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IServerApplicationHost _appHost; + + public RestorePlaylistChildrenFromMetadata( + ILoggerFactory loggerFactory, + IDbContextFactory<JellyfinDbContext> dbProvider, + IServerApplicationHost appHost) + { + _logger = loggerFactory.CreateLogger<RestorePlaylistChildrenFromMetadata>(); + _dbProvider = dbProvider; + _appHost = appHost; + } + + /// <inheritdoc/> + public void Perform() + { + using var context = _dbProvider.CreateDbContext(); + + var playlists = context.BaseItems + .Where(b => b.Type == PlaylistTypeName && b.Path != null) + .Select(b => new { b.Id, b.Name, b.Path }) + .ToList(); + + if (playlists.Count == 0) + { + return; + } + + var childCountByPlaylist = context.LinkedChildren + .Where(lc => context.BaseItems.Any(b => b.Id.Equals(lc.ParentId) && b.Type == PlaylistTypeName)) + .GroupBy(lc => lc.ParentId) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(g => g.ParentId, g => g.Count); + + var pathToIdMap = context.BaseItems + .Where(b => b.Path != null) + .Select(b => new { b.Id, b.Path }) + .GroupBy(b => b.Path!) + .ToDictionary(g => g.Key, g => g.First().Id); + + var restoredPlaylists = 0; + var restoredEntries = 0; + + foreach (var playlist in playlists) + { + // Only directory-based (Jellyfin-managed) playlists keep their entries in playlist.xml. + // A playlist that is itself a file (.m3u and friends) is re-read by the library scan. + var playlistPath = _appHost.ExpandVirtualPath(playlist.Path!); + var metadataPath = Path.Combine(playlistPath, PlaylistFileName); + if (!Directory.Exists(playlistPath) || !File.Exists(metadataPath)) + { + continue; + } + + var storedPaths = ReadEntryPaths(metadataPath, playlist.Id); + if (storedPaths.Count == 0) + { + continue; + } + + var childCount = childCountByPlaylist.GetValueOrDefault(playlist.Id); + if (childCount > 0) + { + // Merging into a playlist that still has entries would resurrect anything the user + // removed while the metadata file was not rewritten, and there is no way to tell the + // two apart. Report the mismatch instead so it can be checked by hand. + if (storedPaths.Count > childCount) + { + _logger.LogWarning( + "Playlist {PlaylistName} ({PlaylistId}) holds {ChildCount} entries but {MetadataPath} lists {StoredCount}. Not restoring automatically.", + playlist.Name, + playlist.Id, + childCount, + metadataPath, + storedPaths.Count); + } + + continue; + } + + var sortOrder = 0; + foreach (var storedPath in storedPaths) + { + if (!pathToIdMap.TryGetValue(storedPath, out var childId)) + { + _logger.LogWarning( + "Cannot restore entry {EntryPath} of playlist {PlaylistName}: no library item has that path.", + storedPath, + playlist.Name); + continue; + } + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = playlist.Id, + ChildId = childId, + ChildType = LinkedChildType.Manual, + SortOrder = sortOrder + }); + + sortOrder++; + } + + if (sortOrder > 0) + { + restoredPlaylists++; + restoredEntries += sortOrder; + _logger.LogInformation( + "Restored {Count} entries of empty playlist {PlaylistName} ({PlaylistId}) from {MetadataPath}.", + sortOrder, + playlist.Name, + playlist.Id, + metadataPath); + } + } + + if (restoredEntries > 0) + { + context.SaveChanges(); + _logger.LogInformation("Restored {EntryCount} entries across {PlaylistCount} playlists.", restoredEntries, restoredPlaylists); + } + } + + private List<string> ReadEntryPaths(string metadataPath, Guid playlistId) + { + var paths = new List<string>(); + var settings = new XmlReaderSettings + { + IgnoreComments = true, + IgnoreWhitespace = true, + IgnoreProcessingInstructions = true, + DtdProcessing = DtdProcessing.Prohibit + }; + + try + { + using var reader = XmlReader.Create(metadataPath, settings); + var inEntry = false; + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element) + { + continue; + } + + if (string.Equals(reader.Name, "PlaylistItem", StringComparison.Ordinal)) + { + inEntry = true; + } + else if (inEntry && string.Equals(reader.Name, "Path", StringComparison.Ordinal)) + { + inEntry = false; + var value = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(value)) + { + paths.Add(value.Trim()); + } + } + } + } + catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Could not read playlist metadata {MetadataPath} of playlist {PlaylistId}.", metadataPath, playlistId); + } + + return paths; + } +} diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 209feac702..28f40cb7fa 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -88,7 +88,7 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Short }; - private static readonly char[] VersionDelimiters = ['-', '_', '.']; + private protected static readonly char[] VersionDelimiters = ['-', '_', '.']; private string _sortName; @@ -771,6 +771,17 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol; + /// <summary> + /// Gets a value indicating whether this item searches the folder it lives in for its own extras. + /// </summary> + [JsonIgnore] + protected virtual bool SearchesContainingFolderForExtras => + IsFileProtocol + && SupportsOwnedItems + && !IsInMixedFolder + && this is not (ICollectionFolder or UserRootFolder or AggregateFolder) + && GetType() != typeof(Folder); + [JsonIgnore] public virtual bool SupportsPeople => false; @@ -1528,7 +1539,14 @@ namespace MediaBrowser.Controller.Entities /// <returns><c>true</c> if any items have changed, else <c>false</c>.</returns> protected virtual async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { - if (!IsFileProtocol || !SupportsOwnedItems || IsInMixedFolder || this is ICollectionFolder or UserRootFolder or AggregateFolder || this.GetType() == typeof(Folder)) + if (!SearchesContainingFolderForExtras) + { + return false; + } + + if (GetParent() is Folder container + && container.SearchesContainingFolderForExtras + && string.Equals(container.Path, ContainingFolderPath, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -1543,19 +1561,33 @@ namespace MediaBrowser.Controller.Entities private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { - var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); - var newExtraIds = Array.ConvertAll(extras, x => x.Id); - + // An extra is owned by the version it is named after, so all of them are maintained together. var currentExtras = LibraryManager.GetItemList(new InternalItemsQuery() { - OwnerIds = [item.Id] - }); + OwnerIds = item.GetOwnedVersionIds() + }).Where(e => e.ExtraType.HasValue).ToList(); var currentExtraIds = currentExtras.Select(e => e.Id).ToArray(); + // Snapshot the persisted names before resolving, as FindExtras corrects the name on the + // items it hands back and may well hand back these very instances. + var currentExtraNames = new Dictionary<Guid, string>(); + foreach (var extra in currentExtras) + { + currentExtraNames[extra.Id] = extra.Name; + } + + var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); + var newExtraIds = Array.ConvertAll(extras, x => x.Id); + + var renamedExtraIds = extras + .Where(e => currentExtraNames.TryGetValue(e.Id, out var oldName) && !string.Equals(oldName, e.Name, StringComparison.Ordinal)) + .Select(e => e.Id) + .ToHashSet(); + var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x)); - if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) + if (!extrasChanged && renamedExtraIds.Count == 0 && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) { // The owner's dates may only have become known after its extras were created, so keep // them in sync even when there is nothing to refresh. @@ -1570,12 +1602,11 @@ namespace MediaBrowser.Controller.Entities return false; } - var ownerId = item.Id; - var tasks = extras.Select(i => { + var ownerId = item.GetOwnerIdForExtra(i); var subOptions = new MetadataRefreshOptions(options); - if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty()) + if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty() || renamedExtraIds.Contains(i.Id)) { subOptions.ForceSave = true; } @@ -2921,6 +2952,25 @@ namespace MediaBrowser.Controller.Entities } /// <summary> + /// Gets the ids of this item and the versions of it whose extras it maintains. + /// </summary> + /// <returns>An array containing the version ids.</returns> + protected virtual Guid[] GetOwnedVersionIds() + { + return [Id]; + } + + /// <summary> + /// Gets the id of the version an extra belongs to. + /// </summary> + /// <param name="extra">The extra.</param> + /// <returns>The id of the owning version.</returns> + protected virtual Guid GetOwnerIdForExtra(BaseItem extra) + { + return Id; + } + + /// <summary> /// Get all extras associated with this item, sorted by <see cref="SortName"/>. /// </summary> /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param> diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index b1f7f29bad..d8203ea6f2 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -43,11 +43,7 @@ namespace MediaBrowser.Controller.Entities public class Folder : BaseItem { private IEnumerable<BaseItem> _children; - - public Folder() - { - LinkedChildren = Array.Empty<LinkedChild>(); - } + private LinkedChild[] _linkedChildren = []; public static IUserViewManager UserViewManager { get; set; } @@ -63,7 +59,27 @@ namespace MediaBrowser.Controller.Entities /// Gets or sets the linked children. /// </summary> [JsonIgnore] - public LinkedChild[] LinkedChildren { get; set; } + public LinkedChild[] LinkedChildren + { + get => _linkedChildren; + set + { + _linkedChildren = value; + + // Assigning the collection means the caller knows the complete set of links. + LinkedChildrenLoaded = true; + } + } + + /// <summary> + /// Gets a value indicating whether <see cref="LinkedChildren"/> holds the stored set of links. + /// </summary> + /// <remarks> + /// An unloaded instance carries an empty array that means "unknown", not "no children" — + /// persisting it would delete every link the item has. + /// </remarks> + [JsonIgnore] + public bool LinkedChildrenLoaded { get; private set; } [JsonIgnore] public DateTime? DateLastMediaAdded { get; set; } @@ -1085,15 +1101,7 @@ namespace MediaBrowser.Controller.Entities items = ApplyNameFilter(items, query); } - var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList(); - var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); - - if (query.EnableTotalRecordCount) - { - result.TotalRecordCount = filteredItems.Count; - } - - return result; + return UserViewBuilder.SortAndPage(items, null, query, LibraryManager); } private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query) diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 3b1f6a961f..e85f86b72f 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -496,6 +496,12 @@ namespace MediaBrowser.Controller.Entities public IReadOnlyList<string> SubtitleLanguages { get; set; } + /// <summary> + /// Gets a value indicating whether some content in the library is hidden from <see cref="User"/>. + /// Filters that only exist to hide content can be skipped entirely when this is false. + /// </summary> + public bool UserHasContentRestrictions { get; private set; } + public void SetUser(User user) { var maxRating = user.MaxParentalRatingScore; @@ -519,6 +525,7 @@ namespace MediaBrowser.Controller.Entities .Select(tag => tag.RemoveDiacritics().ToLowerInvariant()) .ToArray(); + UserHasContentRestrictions = user.HasContentRestrictions(); User = user; } diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index e12ba22343..8d2a959f4d 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -19,8 +19,16 @@ namespace MediaBrowser.Controller.Entities { PersonTypes = personTypes; ExcludePersonTypes = excludePersonTypes; + EnableTotalRecordCount = true; } + /// <summary> + /// Gets or sets a value indicating whether to count the matching people. Under an + /// <see cref="AccessFilter"/> the count is the expensive half of the query: the page walk stops + /// at the limit, the count has to check every person. + /// </summary> + public bool EnableTotalRecordCount { get; set; } + public int? StartIndex { get; set; } /// <summary> @@ -51,5 +59,11 @@ namespace MediaBrowser.Controller.Entities public User User { get; set; } public bool? IsFavorite { get; set; } + + /// <summary> + /// Gets or sets the item query whose access settings (library access, parental rating, tags) + /// people must satisfy through at least one of the items they are credited on. + /// </summary> + public InternalItemsQuery AccessFilter { get; set; } } } diff --git a/MediaBrowser.Controller/Entities/PeopleHelper.cs b/MediaBrowser.Controller/Entities/PeopleHelper.cs index 24b1843ce6..29f238d8ea 100644 --- a/MediaBrowser.Controller/Entities/PeopleHelper.cs +++ b/MediaBrowser.Controller/Entities/PeopleHelper.cs @@ -35,57 +35,61 @@ namespace MediaBrowser.Controller.Entities person.Type = PersonKind.Writer; } - // If the type is GuestStar and there's already an Actor entry, then update it to avoid dupes - if (person.Type == PersonKind.GuestStar) - { - var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && p.Type == PersonKind.Actor); + // Check for dupes based on the combination of Name, Type and Role. + var existing = people.FirstOrDefault(p => IsSameCredit(p, person) + && string.Equals(p.Role ?? string.Empty, person.Role ?? string.Empty, StringComparison.OrdinalIgnoreCase)); - if (existing is not null) - { - existing.Type = PersonKind.GuestStar; - MergeExisting(existing, person); - return; - } - } - - if (person.Type == PersonKind.Actor) + if (existing is null) { - // If the actor already exists without a role and we have one, fill it in - var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && (p.Type == PersonKind.Actor || p.Type == PersonKind.GuestStar)); - if (existing is null) + if (string.IsNullOrEmpty(person.Role)) { - // Wasn't there - add it - people.Add(person); + existing = people.FirstOrDefault(p => IsSameCredit(p, person)); } else { - // Was there, if no role and we have one - fill it in - if (string.IsNullOrEmpty(existing.Role) && !string.IsNullOrEmpty(person.Role)) + // If the person already exists without a role and we have one, fill it in + existing = people.FirstOrDefault(p => IsSameCredit(p, person) && string.IsNullOrEmpty(p.Role)); + if (existing is not null) { existing.Role = person.Role; } - - MergeExisting(existing, person); } } - else + + if (existing is null) { - var existing = people.FirstOrDefault(p => - string.Equals(p.Name, person.Name, StringComparison.OrdinalIgnoreCase) - && p.Type == person.Type); + people.Add(person); + return; + } - // Check for dupes based on the combination of Name and Type - if (existing is null) - { - people.Add(person); - } - else - { - MergeExisting(existing, person); - } + // If the type is GuestStar and there's already an Actor entry, then promote it to avoid dupes + if (person.Type == PersonKind.GuestStar) + { + existing.Type = PersonKind.GuestStar; } + + MergeExisting(existing, person); } + private static bool IsSameCredit(PersonInfo existing, PersonInfo person) + { + if (!string.Equals(existing.Name, person.Name, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Actor and GuestStar describe the same credit, a guest star is just a promoted actor. + if (IsCastKind(existing.Type) && IsCastKind(person.Type)) + { + return true; + } + + return existing.Type == person.Type; + } + + private static bool IsCastKind(PersonKind kind) + => kind is PersonKind.Actor or PersonKind.GuestStar; + private static void MergeExisting(PersonInfo existing, PersonInfo person) { existing.SortOrder = person.SortOrder ?? existing.SortOrder; diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 42e4f79942..40f917d50c 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -47,7 +47,7 @@ namespace MediaBrowser.Controller.Entities.TV public int? IndexNumberEnd { get; set; } [JsonIgnore] - protected override bool SupportsOwnedItems => IsStacked || MediaSourceCount > 1; + protected override bool SupportsOwnedItems => IsStacked || LocalAlternateVersions.Length > 0 || MediaSourceCount > 1; [JsonIgnore] public override bool SupportsInheritedParentImages => true; diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 9ba103cc8b..f9ad2d86e6 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -461,11 +461,12 @@ namespace MediaBrowser.Controller.Entities var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); var isPlayedValue = query.IsPlayed.Value; - return itemList.Where(i => + return itemList.Where(item => { - if (i.IsFolder && counts.TryGetValue(i.Id, out var c)) + if (item is Folder) { - return (c.Total > 0 && c.Played == c.Total) == isPlayedValue; + var itemCount = counts.GetValueOrDefault(item.Id); + return (itemCount.Played >= itemCount.Total) == isPlayedValue; } return true; @@ -490,6 +491,13 @@ namespace MediaBrowser.Controller.Entities } var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray(); + + // Adjacency is defined by the order the query asked for, so it has to run after sorting but before paging. + if (!query.AdjacentTo.IsNullOrEmpty()) + { + itemsArray = FilterForAdjacency(itemsArray, query.AdjacentTo.Value).ToArray(); + } + var totalCount = itemsArray.Length; if (query.Limit.HasValue && query.Limit.Value > 0) @@ -886,26 +894,32 @@ namespace MediaBrowser.Controller.Entities return _userViewManager.GetUserSubView(parent.Id, type, localizationKey, sortName); } - public static IEnumerable<BaseItem> FilterForAdjacency(List<BaseItem> list, Guid adjacentTo) + /// <summary> + /// Trims an ordered list down to the requested item and its immediate neighbours. + /// </summary> + /// <param name="list">The items in the order the query returned them.</param> + /// <param name="adjacentTo">The id of the item to return the neighbours of.</param> + /// <returns>The previous item, the requested item and the next item, in order.</returns> + public static IEnumerable<BaseItem> FilterForAdjacency(IReadOnlyList<BaseItem> list, Guid adjacentTo) { - var adjacentToItem = list.FirstOrDefault(i => i.Id.Equals(adjacentTo)); - - var index = list.IndexOf(adjacentToItem); - - var previousId = Guid.Empty; - var nextId = Guid.Empty; - - if (index > 0) + var index = -1; + for (var i = 0; i < list.Count; i++) { - previousId = list[index - 1].Id; + if (list[i].Id.Equals(adjacentTo)) + { + index = i; + break; + } } - if (index < list.Count - 1) + // The item isn't part of this result set, so it has no neighbours in it either. + if (index < 0) { - nextId = list[index + 1].Id; + return []; } - return list.Where(i => i.Id.Equals(previousId) || i.Id.Equals(nextId) || i.Id.Equals(adjacentTo)); + var start = Math.Max(index - 1, 0); + return list.Skip(start).Take(Math.Min(index + 2, list.Count) - start); } } } diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 0606fe1870..e2f91aa04a 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -527,7 +527,13 @@ namespace MediaBrowser.Controller.Entities protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { - var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); + var hasChanges = false; + + // The extras of a version group are maintained by its primary. + if (!PrimaryVersionId.HasValue) + { + hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); + } // Clean up LocalAlternateVersions - remove paths that no longer exist if (LocalAlternateVersions.Length > 0) @@ -588,10 +594,20 @@ namespace MediaBrowser.Controller.Entities { altVideo.OwnerId = Id; altVideo.SetPrimaryVersionId(Id); + altVideo.IsInMixedFolder = IsInMixedFolder; LibraryManager.CreateItem(altVideo, GetParent()); } } + // A version is resolved on its own, so it does not learn whether the folder it sits in + // holds other items. It has to share that with the version it belongs to, before the + // refresh below acts on it. + if (LibraryManager.GetItemById(id) is Video resolvedVersion && resolvedVersion.IsInMixedFolder != IsInMixedFolder) + { + resolvedVersion.IsInMixedFolder = IsInMixedFolder; + await resolvedVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false); // Create LinkedChild entry for this local alternate version @@ -671,6 +687,7 @@ namespace MediaBrowser.Controller.Entities video.Id = id; video.OwnerId = Id; + video.IsInMixedFolder = IsInMixedFolder; LibraryManager.CreateItem(video, parentFolder); newOptions.ForceSave = true; } @@ -751,6 +768,80 @@ namespace MediaBrowser.Controller.Entities .ToArray(); } + /// <inheritdoc /> + protected override Guid[] GetOwnedVersionIds() + { + // Only the versions that live beside this one in the folder this scan covers. Linked + // versions are items of their own and maintain their extras themselves. + return [Id, .. LibraryManager.GetLocalAlternateVersionIds(this)]; + } + + /// <inheritdoc /> + protected override Guid GetOwnerIdForExtra(BaseItem extra) + { + if (string.IsNullOrEmpty(extra.Path)) + { + return Id; + } + + var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path.AsSpan()); + var extraFileName = System.IO.Path.GetFileNameWithoutExtension(extra.Path.AsSpan()); + + var ownerId = Id; + var matchedLength = MatchedVersionNameLength(Path, extraDirectory, extraFileName); + + foreach (var versionId in LibraryManager.GetLocalAlternateVersionIds(this)) + { + var version = LibraryManager.GetItemById(versionId); + if (version is null) + { + continue; + } + + // "Movie - [2160p]-trailer.mkv" belongs to "Movie - [2160p].mkv" rather than to the + // primary version, whose name it also starts with when the primary is plain "Movie.mkv" + var length = MatchedVersionNameLength(version.Path, extraDirectory, extraFileName); + if (length > matchedLength) + { + matchedLength = length; + ownerId = versionId; + } + } + + return ownerId; + } + + /// <summary> + /// Gets how much of an extra's file name is the name of the given version file, or 0 when the + /// extra is not named after it. + /// </summary> + /// <param name="versionPath">The path of the version.</param> + /// <param name="extraDirectory">The directory the extra lives in.</param> + /// <param name="extraFileName">The file name of the extra, without extension.</param> + /// <returns>The length of the match.</returns> + private static int MatchedVersionNameLength(string versionPath, ReadOnlySpan<char> extraDirectory, ReadOnlySpan<char> extraFileName) + { + if (string.IsNullOrEmpty(versionPath) + || !System.IO.Path.GetDirectoryName(versionPath.AsSpan()).Equals(extraDirectory, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + var versionFileName = System.IO.Path.GetFileNameWithoutExtension(versionPath.AsSpan()); + if (versionFileName.IsEmpty || !extraFileName.StartsWith(versionFileName, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + // The version name has to end where the extra's own name begins, so that a version + // named "Movie - 4K" does not claim the extras of "Movie - 4Kish" + var remainder = extraFileName[versionFileName.Length..]; + + return !remainder.IsEmpty && (remainder[0] == ' ' || Array.IndexOf(VersionDelimiters, remainder[0]) >= 0) + ? versionFileName.Length + : 0; + } + protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() { var primary = PrimaryVersionId.HasValue diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 1b0bbe9ea0..9a68889352 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -7864,10 +7864,16 @@ namespace MediaBrowser.Controller.MediaEncoding audioTranscodeParams.Add("-acodec " + GetAudioEncoder(state)); } - if (GetAudioEncoder(state).StartsWith("pcm_", StringComparison.Ordinal)) - { - audioTranscodeParams.Add(string.Concat("-f ", GetAudioEncoder(state).AsSpan(4))); - audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate); + // The pcm_* encoders emit raw samples that carry no header of their own, so the header + // has to come from the muxer. Only force the matching raw muxer when the client actually + // asked for a raw container (added in #10321 for I2S/MCU clients): applying it to every + // pcm_* codec also strips the RIFF header from a `stream.wav` request, which then serves + // headerless PCM behind an audio/wav content type. + var audioEncoder = GetAudioEncoder(state); + if (audioEncoder.StartsWith("pcm_", StringComparison.Ordinal) + && string.Equals(state.OutputContainer, "pcm", StringComparison.OrdinalIgnoreCase)) + { + audioTranscodeParams.Add(string.Concat("-f ", audioEncoder.AsSpan(4))); } var sampleRate = state.OutputAudioSampleRate; diff --git a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs index 2e29cbdbba..f9a050d591 100644 --- a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs +++ b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Linq.Expressions; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; @@ -79,17 +80,26 @@ public interface IItemQueryHelpers Guid ancestorId); /// <summary> - /// Builds an <see cref="IQueryable{Guid}"/> of folder IDs whose descendants are all played - /// for the given user. Composable into outer queries to avoid an extra DB roundtrip. + /// Builds a query for the playable leaf items a user can access. /// </summary> /// <param name="context">The database context the resulting query is bound to.</param> - /// <param name="folderIds">A query yielding candidate folder IDs.</param> - /// <param name="user">The user for access filtering and played status.</param> - /// <returns>An <see cref="IQueryable{Guid}"/> of fully-played folder IDs.</returns> - IQueryable<Guid> GetFullyPlayedFolderIdsQuery( + /// <param name="user">The user to filter accessible items for.</param> + /// <param name="includeOwnedItems">Whether to include alternate versions and owned items.</param> + /// <returns>The access-filtered leaf item queryable.</returns> + IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery( JellyfinDbContext context, - IQueryable<Guid> folderIds, - User user); + User user, + bool includeOwnedItems = false); + + /// <summary> + /// Builds a filter matching items that have at least one of <paramref name="descendants"/> below them. + /// </summary> + /// <param name="context">The database context the resulting filter is bound to.</param> + /// <param name="descendants">A query yielding the descendants to look for.</param> + /// <returns>A filter expression matching items with a matching descendant.</returns> + Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter( + JellyfinDbContext context, + IQueryable<BaseItemEntity> descendants); /// <summary> /// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>. diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index a9ab7d6db0..ab8d5dd5b2 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -1582,7 +1582,11 @@ namespace MediaBrowser.Model.Dlna continue; } - if (!subtitleStream.IsExternal && playMethod == PlayMethod.Transcode && !transcoderSupport.CanExtractSubtitles(subtitleStream.Codec)) + if (!subtitleStream.IsExternal + && playMethod == PlayMethod.Transcode + && !transcoderSupport.CanExtractSubtitles(subtitleStream.Codec) + && !subtitleStream.IsPgsSubtitleStream + && !subtitleStream.IsVobSubSubtitleStream) { continue; } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index f09c4c876c..40f2775bd3 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -1038,6 +1038,11 @@ namespace MediaBrowser.Providers.Manager target.OriginalTitle = source.OriginalTitle; } + if (replaceData || string.IsNullOrEmpty(target.HomePageUrl)) + { + target.HomePageUrl = source.HomePageUrl; + } + if (replaceData || string.IsNullOrEmpty(target.OriginalLanguage)) { target.OriginalLanguage = source.OriginalLanguage; diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 73df6d03d2..45fbe4d348 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -436,6 +436,14 @@ namespace MediaBrowser.Providers.Manager return false; } + // Extras have no identity of their own in an online database, so remote artwork for them + // is always some other item's. Local and dynamic providers still apply, so an extra can + // keep an embedded thumbnail or an extracted frame. + if (item.ExtraType.HasValue && provider is IRemoteImageProvider) + { + return false; + } + return _baseItemManager.IsImageFetcherEnabled(item, libraryTypeOptions, provider.Name); } @@ -584,6 +592,14 @@ namespace MediaBrowser.Providers.Manager return true; } + // An extra is a local file belonging to another item and has no identity of its own in an + // online database. Looking it up matches whatever the surrounding folder happens to be + // called and overwrites the extra's name with a different item's title. + if (item.ExtraType.HasValue) + { + return false; + } + // Artists without a folder structure that are derived from metadata have no real path in the library, // so GetLibraryOptions returns null. Allow all providers through rather than blocking them. if (item is MusicArtist && libraryTypeOptions is null) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs index 88730f34d2..28cfc8f9a4 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs @@ -3,32 +3,24 @@ #pragma warning disable CS1591 using System.Collections.Generic; -using System.IO; using System.Net.Http; -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; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.AudioDb { public class AudioDbArtistImageProvider : IRemoteImageProvider, IHasOrder { - private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - public AudioDbArtistImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory) + public AudioDbArtistImageProvider(IHttpClientFactory httpClientFactory) { - _config = config; _httpClientFactory = httpClientFactory; } @@ -54,22 +46,14 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { - if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var id)) - { - await AudioDbArtistProvider.Current.EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false); - - var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id); + item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var musicBrainzId); + item.TryGetProviderId(MetadataProvider.AudioDbArtist, out var audioDbId); - FileStream jsonStream = AsyncFile.OpenRead(path); - await using (jsonStream.ConfigureAwait(false)) - { - var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var artist = await AudioDbArtistProvider.Current.GetArtist(musicBrainzId, audioDbId, cancellationToken).ConfigureAwait(false); - if (obj is not null && obj.artists is not null && obj.artists.Count > 0) - { - return GetImages(obj.artists[0]); - } - } + if (artist is not null) + { + return GetImages(artist); } return []; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index d8cb6b4b24..c4f4833857 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -4,9 +4,11 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; +using System.Net.Http.Json; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -52,45 +54,176 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public int Order => 1; /// <inheritdoc /> - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult(Enumerable.Empty<RemoteSearchResult>()); - - /// <inheritdoc /> - public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) { - var result = new MetadataResult<MusicArtist>(); - var id = info.GetMusicBrainzArtistId(); + // Prefer a known TheAudioDB artist id. + var audioDbId = searchInfo.GetProviderId(MetadataProvider.AudioDbArtist); + if (!string.IsNullOrWhiteSpace(audioDbId)) + { + var artists = await FetchArtists(BaseUrl + "/artist.php?i=" + audioDbId, cancellationToken).ConfigureAwait(false); + return artists.Select(ToRemoteSearchResult); + } - if (!string.IsNullOrWhiteSpace(id)) + // Fall back to the MusicBrainz artist id, reusing the on-disk cache also used by GetMetadata. + var musicBrainzId = searchInfo.GetMusicBrainzArtistId(); + if (!string.IsNullOrWhiteSpace(musicBrainzId)) { - await EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false); + await EnsureArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false); - var path = GetArtistInfoPath(_config.ApplicationPaths, id); + var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); FileStream jsonStream = AsyncFile.OpenRead(path); await using (jsonStream.ConfigureAwait(false)) { var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (obj is not null && obj.artists is not null && obj.artists.Count > 0) + if (obj is not null && obj.artists is not null) { - result.Item = new MusicArtist(); - result.HasMetadata = true; - ProcessResult(result.Item, obj.artists[0], info.MetadataLanguage); + return obj.artists.Select(ToRemoteSearchResult); } } + + return []; + } + + // Finally, search by name. + if (!string.IsNullOrWhiteSpace(searchInfo.Name)) + { + var artists = await FetchArtists(BaseUrl + "/search.php?s=" + Uri.EscapeDataString(searchInfo.Name), cancellationToken).ConfigureAwait(false); + return artists.Select(ToRemoteSearchResult); + } + + return []; + } + + private async Task<List<Artist>> FetchArtists(string url, CancellationToken cancellationToken) + { + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var obj = await response.Content.ReadFromJsonAsync<RootObject>(_jsonOptions, cancellationToken).ConfigureAwait(false); + + return obj?.artists ?? []; + } + + private RemoteSearchResult ToRemoteSearchResult(Artist artist) + { + var result = new RemoteSearchResult + { + Name = artist.strArtist, + ImageUrl = artist.strArtistThumb, + SearchProviderName = Name, + Overview = (artist.strBiographyEN ?? string.Empty).StripHtml() + }; + + if (!string.IsNullOrEmpty(artist.idArtist)) + { + result.SetProviderId(MetadataProvider.AudioDbArtist, artist.idArtist); + } + + if (!string.IsNullOrEmpty(artist.strMusicBrainzID)) + { + result.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.strMusicBrainzID); + } + + if (int.TryParse(artist.intFormedYear, NumberStyles.Integer, CultureInfo.InvariantCulture, out var formedYear)) + { + result.ProductionYear = formedYear; } return result; } + /// <inheritdoc /> + public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<MusicArtist>(); + + var artist = await GetArtist( + info.GetMusicBrainzArtistId(), + info.GetProviderId(MetadataProvider.AudioDbArtist), + cancellationToken).ConfigureAwait(false); + + if (artist is not null) + { + result.Item = new MusicArtist(); + result.HasMetadata = true; + ProcessResult(result.Item, artist, info.MetadataLanguage); + } + + return result; + } + + /// <summary> + /// Resolves the cached AudioDB artist, preferring the MusicBrainz id and falling back to the AudioDB id. + /// </summary> + /// <param name="musicBrainzId">The MusicBrainz artist id, if known.</param> + /// <param name="audioDbId">The TheAudioDB artist id, if known.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The matching artist, or <c>null</c> if none could be resolved.</returns> + internal async Task<Artist> GetArtist(string musicBrainzId, string audioDbId, CancellationToken cancellationToken) + { + string path; + if (!string.IsNullOrWhiteSpace(musicBrainzId)) + { + await EnsureArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false); + path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); + } + else if (!string.IsNullOrWhiteSpace(audioDbId)) + { + await EnsureArtistInfoByAudioDbId(audioDbId, cancellationToken).ConfigureAwait(false); + path = GetArtistInfoPath(_config.ApplicationPaths, audioDbId); + } + else + { + return null; + } + + FileStream jsonStream = AsyncFile.OpenRead(path); + await using (jsonStream.ConfigureAwait(false)) + { + var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + + if (obj is not null && obj.artists is not null && obj.artists.Count > 0) + { + return obj.artists[0]; + } + } + + return null; + } + private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage) { - // item.HomePageUrl = result.strWebsite; + if (!string.IsNullOrWhiteSpace(result.strWebsite)) + { + item.HomePageUrl = result.strWebsite; + } + + var genres = new List<string>(); + if (!string.IsNullOrWhiteSpace(result.strGenre)) + { + genres.Add(result.strGenre); + } + + if (!string.IsNullOrWhiteSpace(result.strSubGenre)) + { + genres.Add(result.strSubGenre); + } + + if (genres.Count > 0) + { + item.Genres = genres.ToArray(); + } + + if (int.TryParse(result.intFormedYear, NumberStyles.Integer, CultureInfo.InvariantCulture, out var formedYear)) + { + item.ProductionYear = formedYear; + } - if (!string.IsNullOrEmpty(result.strGenre)) + if (!string.IsNullOrWhiteSpace(result.strCountry)) { - item.Genres = new[] { result.strGenre }; + item.ProductionLocations = new[] { result.strCountry }; } item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist); @@ -150,13 +283,32 @@ namespace MediaBrowser.Providers.Plugins.AudioDb internal async Task DownloadArtistInfo(string musicBrainzId, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - var url = BaseUrl + "/artist-mb.php?i=" + musicBrainzId; + await DownloadArtistInfo(url, GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId), cancellationToken).ConfigureAwait(false); + } + + internal async Task EnsureArtistInfoByAudioDbId(string audioDbId, CancellationToken cancellationToken) + { + var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, audioDbId); + + var fileInfo = _fileSystem.GetFileSystemInfo(xmlPath); + + if (fileInfo.Exists + && (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2) + { + return; + } + + var url = BaseUrl + "/artist.php?i=" + audioDbId; + await DownloadArtistInfo(url, xmlPath, cancellationToken).ConfigureAwait(false); + } + + private async Task DownloadArtistInfo(string url, string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); Directory.CreateDirectory(Path.GetDirectoryName(path)); var fileStreamOptions = AsyncFile.WriteOptions; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 715bdd9da4..397c916a4f 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -155,7 +155,6 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu /// <inheritdoc /> public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) { - // TODO: This sets essentially nothing. As-is, it's mostly useless. Make it actually pull metadata and use it. var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery; var releaseId = info.GetReleaseId(); var releaseGroupId = info.GetReleaseGroupId(); @@ -169,13 +168,8 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId)) { // TODO: Actually try to match the release. Simply taking the first result is stupid. - var releaseGroup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false); - var release = releaseGroup.Releases?.Count > 0 ? releaseGroup.Releases[0] : null; - if (release is not null) - { - releaseId = release.Id.ToString(); - result.HasMetadata = true; - } + var releaseGroupLookup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false); + releaseId = releaseGroupLookup.Releases?.Count > 0 ? releaseGroupLookup.Releases[0].Id.ToString() : null; } // If there is no release ID, lookup a release with the info we have @@ -205,43 +199,106 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu { releaseGroupId = releaseResult.ReleaseGroup.Id.ToString(); } - - result.HasMetadata = true; - result.Item.ProductionYear = releaseResult.Date?.Year; - result.Item.Overview = releaseResult.Annotation; } } - // If we have a release ID but not a release group ID, lookup the release group - if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) + if (string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) { - var release = await query.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); - releaseGroupId = release.ReleaseGroup?.Id.ToString(); - result.HasMetadata = true; + return result; } - // If we have a release ID and a release group ID - if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId)) + // Fetch the full release (and its release group) so we can populate everything MusicBrainz returns. + IRelease? release = null; + if (!string.IsNullOrWhiteSpace(releaseId)) { - result.HasMetadata = true; - } + release = await query.LookupReleaseAsync( + new Guid(releaseId), + Include.Artists | Include.ReleaseGroups | Include.Labels | Include.Genres | Include.Tags, + cancellationToken).ConfigureAwait(false); - if (result.HasMetadata) - { - if (!string.IsNullOrEmpty(releaseId)) + if (string.IsNullOrWhiteSpace(releaseGroupId) && release?.ReleaseGroup?.Id is not null) { - result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId); + releaseGroupId = release.ReleaseGroup.Id.ToString(); } + } - if (!string.IsNullOrEmpty(releaseGroupId)) - { - result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId); - } + IReleaseGroup? releaseGroup = null; + if (!string.IsNullOrWhiteSpace(releaseGroupId)) + { + releaseGroup = await query.LookupReleaseGroupAsync( + new Guid(releaseGroupId), + Include.Artists | Include.Genres | Include.Tags, + null, + cancellationToken).ConfigureAwait(false); + } + + result.HasMetadata = true; + + if (!string.IsNullOrEmpty(releaseId)) + { + result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId); + } + + if (!string.IsNullOrEmpty(releaseGroupId)) + { + result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId); } + Populate(result.Item, release, releaseGroup); + return result; } + private static void Populate(MusicAlbum item, IRelease? release, IReleaseGroup? releaseGroup) + { + // Prefer the release group (album-level) data, falling back to the specific release. + // The release group's first release date is the original album date. + var date = releaseGroup?.FirstReleaseDate ?? release?.Date; + if (date is not null) + { + item.PremiereDate = date.NearestDate; + item.ProductionYear = date.Year; + } + + var artistCredit = release?.ArtistCredit ?? releaseGroup?.ArtistCredit; + if (artistCredit is not null && artistCredit.Count > 0) + { + item.AlbumArtists = artistCredit + .Select(credit => credit.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); + } + + var genres = releaseGroup?.Genres ?? release?.Genres; + if (genres is not null && genres.Count > 0) + { + item.Genres = genres + .OrderByDescending(genre => genre.VoteCount) + .Select(genre => genre.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); + } + + var tags = releaseGroup?.Tags ?? release?.Tags; + if (tags is not null && tags.Count > 0) + { + item.Tags = tags + .OrderByDescending(tag => tag.VoteCount) + .Select(tag => tag.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); + } + + if (release?.LabelInfo is not null && release.LabelInfo.Count > 0) + { + item.Studios = release.LabelInfo + .Where(labelInfo => !string.IsNullOrWhiteSpace(labelInfo.Label?.Name)) + .Select(labelInfo => labelInfo.Label!.Name!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index 0fe4e6bb16..a9e950fb64 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -40,6 +40,11 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar return GetResultFromResponse(artistResult).SingleItemAsEnumerable(); } + if (string.IsNullOrWhiteSpace(searchInfo.Name)) + { + return []; + } + var artistSearchResults = await query.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken) .ConfigureAwait(false); if (artistSearchResults.Results.Count > 0) @@ -58,7 +63,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar } } - return Enumerable.Empty<RemoteSearchResult>(); + return []; } private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<ISearchResult<IArtist>>? releaseSearchResults) @@ -96,28 +101,67 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar var musicBrainzId = info.GetMusicBrainzArtistId(); + // If we don't have an id yet, resolve one by name so we can look the artist up. if (string.IsNullOrWhiteSpace(musicBrainzId)) { var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false); + musicBrainzId = searchResults.FirstOrDefault()?.GetProviderId(MetadataProvider.MusicBrainzArtist); + } - var singleResult = searchResults.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(musicBrainzId)) + { + return result; + } - if (singleResult is not null) - { - musicBrainzId = singleResult.GetProviderId(MetadataProvider.MusicBrainzArtist); - result.Item.Overview = singleResult.Overview; + var query = Plugin.Instance!.MusicBrainzQuery; + var artist = await query.LookupArtistAsync(new Guid(musicBrainzId), Include.Genres | Include.Tags, null, null, cancellationToken).ConfigureAwait(false); - if (Plugin.Instance!.Configuration.ReplaceArtistName) - { - result.Item.Name = singleResult.Name; - } - } + if (artist is null) + { + return result; + } + + result.HasMetadata = true; + result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Id.ToString()); + + if (Plugin.Instance!.Configuration.ReplaceArtistName && !string.IsNullOrWhiteSpace(artist.Name)) + { + result.Item.Name = artist.Name; + } + + if (artist.LifeSpan?.Begin is not null) + { + result.Item.PremiereDate = artist.LifeSpan.Begin.NearestDate; + result.Item.ProductionYear = artist.LifeSpan.Begin.Year; + } + + if (artist.LifeSpan?.End is not null) + { + result.Item.EndDate = artist.LifeSpan.End.NearestDate; + } + + var location = string.IsNullOrWhiteSpace(artist.Area?.Name) ? artist.Country : artist.Area!.Name; + if (!string.IsNullOrWhiteSpace(location)) + { + result.Item.ProductionLocations = [location]; + } + + if (artist.Genres is not null && artist.Genres.Count > 0) + { + result.Item.Genres = artist.Genres + .OrderByDescending(genre => genre.VoteCount) + .Select(genre => genre.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); } - if (!string.IsNullOrWhiteSpace(musicBrainzId)) + if (artist.Tags is not null && artist.Tags.Count > 0) { - result.HasMetadata = true; - result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzId); + result.Item.Tags = artist.Tags + .OrderByDescending(tag => tag.VoteCount) + .Select(tag => tag.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); } return result; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index 78405c21fc..b3a67189bb 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1819 // Properties should not return arrays + using MediaBrowser.Model.Plugins; namespace MediaBrowser.Providers.Plugins.Tmdb @@ -34,6 +36,51 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public bool ImportSeasonName { get; set; } /// <summary> + /// Gets or sets a value indicating whether unaired (upcoming) episodes should be created as + /// virtual items from the episode list provided by TMDb. These populate the "Upcoming" view. + /// Enabling this will increase scan times. + /// </summary> + public bool ImportUnairedEpisodes { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether already aired episodes that are not present in the + /// library should be created as virtual items from the episode list provided by TMDb. These + /// surface as missing episodes. Enabling this will increase scan times. + /// </summary> + public bool ImportMissingEpisodes { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether specials (season 0) should be included when creating + /// virtual unaired or missing episodes. When disabled, specials are never added and any existing + /// virtual specials created by this provider are removed. + /// </summary> + public bool ImportSpecials { get; set; } + + /// <summary> + /// Gets or sets the ids (the "N" formatted GUIDs from <c>VirtualFolderInfo.ItemId</c>) of the + /// libraries for which the unaired/missing episode provider is enabled. Whether episodes are + /// imported at all, and how, is still controlled by the global toggles above; those toggles only + /// apply to the libraries listed here. Libraries not listed, including newly added ones, are + /// never processed, so an empty list disables the feature entirely. + /// </summary> + public string[] EnabledMissingEpisodeLibraries { get; set; } = []; + + /// <summary> + /// Gets or sets how often, in days, the scheduled task re-checks TMDb for newly announced + /// unaired or missing episodes. This is what keeps the "Upcoming" view current for series + /// whose local files have not changed. + /// </summary> + public int MissingEpisodeRefreshIntervalDays { get; set; } = 7; + + /// <summary> + /// Gets or sets the number of days a virtual episode is retained after it airs before it is + /// pruned (when missing episode import is disabled). This grace period leaves recently aired + /// episodes in place to allow for the delay between an episode airing and its file being added + /// to the library. + /// </summary> + public int UpcomingEpisodeGracePeriodDays { get; set; } = 7; + + /// <summary> /// Gets or sets a value indicating the maximum number of cast members to fetch for an item. /// </summary> public int MaxCastMembers { get; set; } = 15; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index 4048fc1655..582753759f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -25,6 +25,40 @@ <input is="emby-checkbox" type="checkbox" id="importSeasonName" /> <span>Import season name from metadata fetched for series.</span> </label> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label> + <input is="emby-checkbox" type="checkbox" id="importUnairedEpisodes" /> + <span>Create unaired (upcoming) episodes from metadata fetched for series.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Adds virtual entries for episodes listed on TMDb that have not aired yet. This populates the "Upcoming" view. Specials are never added to the "Upcoming" view.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label> + <input is="emby-checkbox" type="checkbox" id="importMissingEpisodes" /> + <span>Create missing episodes from metadata fetched for series.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Adds virtual entries for episodes listed on TMDb that have already aired but are not present in your library. Missing episodes are only shown when enabled in the user display preferences. Both options increase scan times.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label> + <input is="emby-checkbox" type="checkbox" id="importSpecials" /> + <span>Include specials when creating unaired and missing episodes.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">When disabled, specials (season 0) are never added as virtual entries and any existing virtual specials are removed.</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="number" id="missingEpisodeRefreshIntervalDays" pattern="[0-9]*" required min="1" max="365" label="Episode refresh interval (days)" /> + <div class="fieldDescription">How often the scheduled task re-checks TMDb for newly announced unaired or missing episodes. Keeps the "Upcoming" view current for series whose local files have not changed.</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="number" id="upcomingEpisodeGracePeriodDays" pattern="[0-9]*" required min="0" max="365" label="Recently aired grace period (days)" /> + <div class="fieldDescription">When missing episodes are disabled, how many days a recently aired episode is kept in place before its placeholder is removed. This allows for the delay between an episode airing and its file being added to the library.</div> + </div> + <div class="verticalSection"> + <h2>Libraries</h2> + <div class="fieldDescription" style="margin-bottom:1em;">Choose which TV libraries the unaired/missing episode options above apply to. The settings above are global; this only controls which libraries they run for. Libraries are opted in individually, so newly added libraries are not processed until they are enabled here.</div> + <div id="missingEpisodeLibraries"></div> + </div> <div class="verticalSection"> <h2>Cast & Crew Settings</h2> <div class="inputContainer"> @@ -85,6 +119,29 @@ Dashboard.showLoadingMsg(); var clientConfig, pluginConfig; + var populateMissingEpisodeLibraries = function (enabledLibraries) { + var container = document.querySelector('#missingEpisodeLibraries'); + ApiClient.getVirtualFolders().then(function (folders) { + // Series only live in TV libraries (and mixed-content libraries, which report + // no collection type), so only those are worth listing here. + var tvLibraries = folders.filter(function (folder) { + return !folder.CollectionType || folder.CollectionType === 'tvshows'; + }); + + if (tvLibraries.length === 0) { + container.innerHTML = '<div class="fieldDescription">No TV libraries found.</div>'; + return; + } + + container.innerHTML = tvLibraries.map(function (folder) { + var checked = enabledLibraries.indexOf(folder.ItemId) === -1 ? '' : ' checked'; + return '<label class="checkboxContainer">' + + '<input is="emby-checkbox" type="checkbox" class="missingEpisodeLibrary" data-library-id="' + folder.ItemId + '"' + checked + ' />' + + '<span>' + folder.Name + '</span>' + + '</label>'; + }).join(''); + }); + } var configureImageScaling = function() { if (clientConfig === undefined || pluginConfig === undefined) { return; @@ -151,9 +208,16 @@ document.querySelector('#excludeTagsSeries').checked = config.ExcludeTagsSeries; document.querySelector('#excludeTagsMovies').checked = config.ExcludeTagsMovies; document.querySelector('#importSeasonName').checked = config.ImportSeasonName; + document.querySelector('#importUnairedEpisodes').checked = config.ImportUnairedEpisodes; + document.querySelector('#importMissingEpisodes').checked = config.ImportMissingEpisodes; + document.querySelector('#importSpecials').checked = config.ImportSpecials; + document.querySelector('#missingEpisodeRefreshIntervalDays').value = config.MissingEpisodeRefreshIntervalDays; + document.querySelector('#upcomingEpisodeGracePeriodDays').value = config.UpcomingEpisodeGracePeriodDays; document.querySelector('#hideMissingCastMembers').checked = config.HideMissingCastMembers; document.querySelector('#hideMissingCrewMembers').checked = config.HideMissingCrewMembers; + populateMissingEpisodeLibraries(config.EnabledMissingEpisodeLibraries || []); + var maxCastMembers = document.querySelector('#maxCastMembers'); maxCastMembers.value = config.MaxCastMembers; maxCastMembers.dispatchEvent(new Event('change', { @@ -189,6 +253,17 @@ config.ExcludeTagsSeries = document.querySelector('#excludeTagsSeries').checked; config.ExcludeTagsMovies = document.querySelector('#excludeTagsMovies').checked; config.ImportSeasonName = document.querySelector('#importSeasonName').checked; + config.ImportUnairedEpisodes = document.querySelector('#importUnairedEpisodes').checked; + config.ImportMissingEpisodes = document.querySelector('#importMissingEpisodes').checked; + config.ImportSpecials = document.querySelector('#importSpecials').checked; + config.MissingEpisodeRefreshIntervalDays = parseInt(document.querySelector('#missingEpisodeRefreshIntervalDays').value, 10); + config.UpcomingEpisodeGracePeriodDays = parseInt(document.querySelector('#upcomingEpisodeGracePeriodDays').value, 10); + var libraryCheckboxes = document.querySelectorAll('.missingEpisodeLibrary'); + if (libraryCheckboxes.length > 0) { + config.EnabledMissingEpisodeLibraries = Array.prototype.filter + .call(libraryCheckboxes, function (checkbox) { return checkbox.checked; }) + .map(function (checkbox) { return checkbox.getAttribute('data-library-id'); }); + } config.MaxCastMembers = document.querySelector('#maxCastMembers').value; config.MaxCrewMembers = document.querySelector('#maxCrewMembers').value; config.HideMissingCastMembers = document.querySelector('#hideMissingCastMembers').checked; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs new file mode 100644 index 0000000000..b44361e4e7 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs @@ -0,0 +1,663 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using TMDbLib.Objects.Search; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV +{ + /// <summary> + /// Creates virtual (metadata-only) entries for missing and unaired episodes. + /// </summary> + public class TmdbMissingEpisodeProvider : ICustomMetadataProvider<Series>, IHasItemChangeMonitor, IHasOrder + { + private readonly TmdbClientManager _tmdbClientManager; + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private readonly IProviderManager _providerManager; + private readonly ILogger<TmdbMissingEpisodeProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="TmdbMissingEpisodeProvider"/> class. + /// </summary> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> + /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> + /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param> + /// <param name="providerManager">The <see cref="IProviderManager"/>.</param> + /// <param name="logger">The <see cref="ILogger{TmdbMissingEpisodeProvider}"/>.</param> + public TmdbMissingEpisodeProvider( + TmdbClientManager tmdbClientManager, + ILibraryManager libraryManager, + IFileSystem fileSystem, + IProviderManager providerManager, + ILogger<TmdbMissingEpisodeProvider> logger) + { + _tmdbClientManager = tmdbClientManager; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _providerManager = providerManager; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => TmdbUtils.ProviderName; + + /// <inheritdoc /> + // Run after the remote series provider so the TMDb id and other metadata are available. + public int Order => 100; + + /// <inheritdoc /> + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + // Reporting a change makes this provider (and only this provider) run during an otherwise incremental refresh. + if (Plugin.Instance?.Configuration is null) + { + return false; + } + + return item is Series series && series.HasProviderId(MetadataProvider.Tmdb); + } + + /// <inheritdoc /> + public async Task<ItemUpdateType> FetchAsync(Series item, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + var configuration = Plugin.Instance?.Configuration; + var importUnaired = (configuration?.ImportUnairedEpisodes).GetValueOrDefault(); + var importMissing = (configuration?.ImportMissingEpisodes).GetValueOrDefault(); + + // The provider is inactive for this series when both global imports are off, or the series' + // library has not been opted in. In either case remove every virtual episode (unaired and + // missing alike) it previously created, so disabling the feature cleans up on the next scan. + if ((!importUnaired && !importMissing) || !IsEnabledForLibrary(item)) + { + if (!PruneAllVirtualEpisodes(item)) + { + return ItemUpdateType.None; + } + + item.Children = null; + return ItemUpdateType.MetadataImport; + } + + var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); + if (string.IsNullOrEmpty(tmdbId) + || !int.TryParse(tmdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seriesTmdbId) + || seriesTmdbId <= 0) + { + return ItemUpdateType.None; + } + + var language = item.GetPreferredMetadataLanguage(); + var countryCode = item.GetPreferredMetadataCountryCode(); + var imageLanguages = TmdbUtils.GetImageLanguagesParam(language, countryCode); + + var tmdbSeries = await _tmdbClientManager + .GetSeriesAsync(seriesTmdbId, language, imageLanguages, countryCode, cancellationToken) + .ConfigureAwait(false); + + if (tmdbSeries?.Seasons is null) + { + return ItemUpdateType.None; + } + + var today = DateTime.UtcNow.Date; + + var importSpecials = (configuration?.ImportSpecials).GetValueOrDefault(); + var gracePeriodDays = Math.Max(0, (configuration?.UpcomingEpisodeGracePeriodDays).GetValueOrDefault()); + + // Track every (season, episode) number that already exists (physical or virtual) so we never + // 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 seasonsByNumber = item.GetRecursiveChildren(i => i is Season) + .OfType<Season>() + .Where(s => s.IndexNumber.HasValue) + .GroupBy(s => s.IndexNumber!.Value) + .ToDictionary(g => g.Key, g => g.First()); + + var addedEpisodes = false; + var updatedEpisodes = false; + + foreach (var seasonInfo in tmdbSeries.Seasons) + { + cancellationToken.ThrowIfCancellationRequested(); + + var seasonNumber = seasonInfo.SeasonNumber; + var tmdbSeason = await _tmdbClientManager + .GetSeasonAsync(seriesTmdbId, seasonNumber, language, imageLanguages, countryCode, cancellationToken) + .ConfigureAwait(false); + + if (tmdbSeason?.Episodes is null) + { + continue; + } + + foreach (var tmdbEpisode in tmdbSeason.Episodes) + { + var episodeNumber = (int)tmdbEpisode.EpisodeNumber; + var premiereDate = GetPremiereDate(tmdbEpisode); + + // Skips undated episodes, unaired (upcoming) ones unless upcoming import is enabled, + // already aired ones unless missing import is enabled, and unaired specials entirely. + if (!ShouldImportEpisode(premiereDate, today, importUnaired, importMissing, seasonNumber == 0, importSpecials)) + { + continue; + } + + var key = (seasonNumber, episodeNumber); + + // Already have a virtual episode this provider created, keep metadata in sync with TMDb. + if (updatableEpisodes.TryGetValue(key, out var existingEpisode)) + { + var season = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false); + var changed = UpdateVirtualEpisode(existingEpisode, tmdbEpisode, premiereDate); + + if (!existingEpisode.ParentId.Equals(season.Id)) + { + existingEpisode.SetParent(season); + existingEpisode.SeasonId = season.Id; + existingEpisode.SeasonName = season.Name; + changed = true; + } + + if (string.IsNullOrEmpty(existingEpisode.PresentationUniqueKey)) + { + existingEpisode.PresentationUniqueKey = existingEpisode.CreatePresentationUniqueKey(); + changed = true; + } + + if (changed) + { + await existingEpisode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + updatedEpisodes = true; + } + + // Backfill the still for placeholders created before images were fetched. + if (await EnsureEpisodeImageAsync(existingEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false)) + { + updatedEpisodes = true; + } + + continue; + } + + if (!existingEpisodes.Add(key)) + { + 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); + addedEpisodes = true; + } + } + + var alignedSeasons = await AlignVirtualSeasonSortNamesAsync(seasonsByNumber.Values, cancellationToken).ConfigureAwait(false); + + if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes && !alignedSeasons) + { + return ItemUpdateType.None; + } + + // Invalidate the cached children so that the season creation / cleanup that runs later in + // SeriesMetadataService.AfterMetadataRefresh observes the newly created (and pruned) episodes. + item.Children = null; + + return ItemUpdateType.MetadataImport; + } + + /// <summary> + /// Returns the series' season with the given number, creating (and refreshing) a virtual season + /// when the whole season is missing from the library. + /// </summary> + private async Task<Season> GetOrCreateSeasonAsync(Series series, int seasonNumber, string? seasonName, Dictionary<int, Season> seasonsByNumber, CancellationToken cancellationToken) + { + if (seasonsByNumber.TryGetValue(seasonNumber, out var existingSeason)) + { + return existingSeason; + } + + _logger.LogInformation("Creating virtual season {SeasonNumber} for series {SeriesName}", seasonNumber, series.Name); + + var season = new Season + { + Name = seasonName, + IndexNumber = seasonNumber, + Id = _libraryManager.GetNewItemId( + series.Id.ToString("N", CultureInfo.InvariantCulture) + "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture), + typeof(Season)), + IsVirtualItem = true, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey() + }; + + series.AddChild(season); + await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)), cancellationToken).ConfigureAwait(false); + + seasonsByNumber[seasonNumber] = season; + return season; + } + + /// <summary> + /// Mirrors physical seasons' name-based sort convention onto virtual seasons so they interleave by + /// number instead of jumping ahead. See <see cref="BuildSeasonSortNameTemplate"/> for the details. + /// </summary> + /// <param name="seasons">The series' seasons (physical and virtual).</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><c>true</c> if any virtual season was updated; otherwise <c>false</c>.</returns> + private async Task<bool> AlignVirtualSeasonSortNamesAsync(IEnumerable<Season> seasons, CancellationToken cancellationToken) + { + var seasonList = seasons.ToList(); + var template = BuildSeasonSortNameTemplate(seasonList); + if (template is null) + { + // No physical season sorts by name: virtual seasons already share the bare-index key space. + return false; + } + + var updated = false; + foreach (var season in seasonList) + { + if (!season.IsVirtualItem || !season.IndexNumber.HasValue) + { + continue; + } + + var desired = template(season.IndexNumber.Value); + if (string.Equals(season.ForcedSortName, desired, StringComparison.Ordinal)) + { + continue; + } + + _logger.LogInformation( + "Aligning sort name of virtual season {SeasonNumber} in series {SeriesName} to {SortName}", + season.IndexNumber, + season.SeriesName, + desired); + + season.ForcedSortName = desired; + await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + updated = true; + } + + return updated; + } + + /// <summary> + /// Builds a factory that maps a season number to a forced sort name mirroring a physical, + /// name-sorted sibling season, or <c>null</c> when no physical season sorts by name. + /// </summary> + /// <param name="seasons">The series' seasons (physical and virtual).</param> + /// <returns>A season-number-to-sort-name factory, or <c>null</c> if there is nothing to mirror.</returns> + internal static Func<int, string>? BuildSeasonSortNameTemplate(IEnumerable<Season> seasons) + { + // Season.CreateSortName sorts by the bare padded index ("0003"), but season NFOs give physical + // seasons a name-based forced sort ("Season 01" -> "season 0000000001"). The digit-leading key + // sorts ahead of the letter-leading one, so mirror the sibling's token with each season number. + var reference = seasons.FirstOrDefault(s => + !s.IsVirtualItem && s.IndexNumber.HasValue && !string.IsNullOrEmpty(s.ForcedSortName)); + if (reference is null) + { + return null; + } + + var forced = reference.ForcedSortName!; + + // Locate the last run of digits (the season number) in the sibling's forced sort name. + var end = -1; + var start = -1; + for (var i = forced.Length - 1; i >= 0; i--) + { + if (char.IsDigit(forced[i])) + { + end = end < 0 ? i : end; + start = i; + } + else if (end >= 0) + { + break; + } + } + + if (end < 0) + { + // Sibling has no numeric component to swap; leave virtual seasons on the bare-index key. + return null; + } + + var prefix = forced[..start]; + var suffix = forced[(end + 1)..]; + var width = end - start + 1; + + // The exact zero-padding is cosmetic: ModifySortChunks pads every digit run to 10 characters, + // so "Season 3" and "Season 03" collapse to the same sort key. Keeping the sibling's width just + // makes the stored value read naturally. + return number => prefix + + number.ToString(CultureInfo.InvariantCulture).PadLeft(width, '0') + + suffix; + } + + private bool IsEnabledForLibrary(BaseItem item) + { + var enabledLibraries = Plugin.Instance?.Configuration.EnabledMissingEpisodeLibraries; + if (enabledLibraries is null || enabledLibraries.Length == 0) + { + return false; + } + + // A series can live under more than one collection folder; opting in any one of them is + // enough. An item that belongs to no collection folder cannot be opted in at all. + return _libraryManager.GetCollectionFolders(item).Any(folder => + 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) + { + 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 ourVirtuals = new List<((int Season, int Episode) Key, Episode Episode)>(); + pruned = false; + + // Enumerate by parent rather than via Series.GetEpisodes: on an initial scan the episodes' + // SeriesPresentationUniqueKey is not set yet, so the presentation-key based query would miss + // them. GetRecursiveChildren walks the actual child tree and sees them regardless. + foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>()) + { + // The series is refreshed before its episodes during an initial scan, so a freshly + // resolved physical episode may not have its numbers populated yet. Resolve them from + // the path (in memory, mirroring CreateSeasonsAsync) so we can dedupe against episodes + // the user actually has files for instead of creating virtual duplicates. + if (episode.IsFileProtocol && (!episode.ParentIndexNumber.HasValue || !episode.IndexNumber.HasValue)) + { + try + { + _libraryManager.FillMissingEpisodeNumbersFromPath(episode, false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resolving episode number from path for {Path}", episode.Path); + } + } + + // Virtual episodes this provider created are candidates for metadata sync (and pruning). + var isOurs = episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb); + + if (ShouldPrune(episode, pruneAgedOut, today, gracePeriodDays, importSpecials)) + { + DeleteEpisode(episode, "no longer upcoming and missing episodes are disabled"); + pruned = true; + continue; + } + + if (episode.ParentIndexNumber.HasValue && episode.IndexNumber.HasValue) + { + var key = (episode.ParentIndexNumber.Value, episode.IndexNumber.Value); + keys.Add(key); + + // Defer the ours/physical reconciliation: an episode's virtual counterpart and its + // physical file can appear in either order while walking the tree, so we can only + // decide which of our virtual episodes are superseded once every episode is seen. + if (isOurs) + { + ourVirtuals.Add((key, episode)); + } + else if (!episode.IsVirtualItem) + { + physicalKeys.Add(key); + } + } + } + + // A physical file now exists for one of our placeholders: delete the placeholder here rather + // than updating it (and then leaving RemoveObsoleteEpisodes to delete it moments later). The + // physical key already blocks re-creation via the dedupe set above. + foreach (var (key, episode) in ourVirtuals) + { + if (physicalKeys.Contains(key)) + { + DeleteEpisode(episode, "a physical episode now exists for this slot"); + pruned = true; + } + else + { + // Virtual episodes this provider created are candidates for metadata sync. + updatable[key] = episode; + } + } + + return (keys, updatable); + } + + /// <summary> + /// Removes every virtual episode this provider previously created in the series. + /// </summary> + /// <param name="series">The series to clean up.</param> + /// <returns><c>true</c> if any episode was removed; otherwise <c>false</c>.</returns> + private bool PruneAllVirtualEpisodes(Series series) + { + var pruned = false; + foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>()) + { + if (episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb)) + { + DeleteEpisode(episode, "the TMDb missing episode provider is disabled for this library"); + pruned = true; + } + } + + return pruned; + } + + private void DeleteEpisode(Episode episode, string reason) + { + _logger.LogInformation( + "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}: {Reason}", + episode.ParentIndexNumber, + episode.IndexNumber, + episode.SeriesName, + reason); + + _libraryManager.DeleteItem( + episode, + new DeleteOptions { DeleteFileLocation = false }, + false); + } + + /// <summary> + /// Determines whether a TMDb episode should be imported as a virtual item, based on its air date + /// and the enabled options. Undated episodes are never imported; unaired (today or later) episodes + /// require <paramref name="importUnaired"/>; already aired episodes require <paramref name="importMissing"/>. + /// Specials (season 0) are only imported when <paramref name="importSpecials"/> is enabled. + /// </summary> + /// <param name="premiereDate">The episode air date (UTC), or null if unknown.</param> + /// <param name="today">The current UTC date.</param> + /// <param name="importUnaired">Whether unaired (upcoming) episodes should be imported.</param> + /// <param name="importMissing">Whether already aired missing episodes should be imported.</param> + /// <param name="isSpecial">Whether the episode belongs to the specials season (season 0).</param> + /// <param name="importSpecials">Whether specials should be included.</param> + /// <returns><c>true</c> if the episode should be imported; otherwise <c>false</c>.</returns> + internal static bool ShouldImportEpisode(DateTime? premiereDate, DateTime today, bool importUnaired, bool importMissing, bool isSpecial, bool importSpecials) + { + if (!premiereDate.HasValue) + { + return false; + } + + // Specials are only imported when the user opts in. + if (isSpecial && !importSpecials) + { + return false; + } + + var isUnaired = premiereDate.Value.Date >= today; + return isUnaired ? importUnaired : importMissing; + } + + /// <summary> + /// Determines whether an existing virtual episode created by this provider (carries a TMDb id) + /// should be pruned. Specials are removed entirely unless <paramref name="importSpecials"/> is + /// enabled. Otherwise, when missing episodes are not wanted, an entry is pruned once its air date + /// is more than <paramref name="gracePeriodDays"/> in the past; the grace period keeps recently + /// aired episodes in place to allow for the delay between an episode airing and its file being + /// added to the library. + /// </summary> + /// <param name="episode">The episode to evaluate.</param> + /// <param name="pruneAgedOut">Whether aged-out virtual episodes should be pruned (missing import disabled).</param> + /// <param name="today">The current UTC date.</param> + /// <param name="gracePeriodDays">The number of days an aired episode is retained before pruning.</param> + /// <param name="importSpecials">Whether specials should be kept.</param> + /// <returns><c>true</c> if the episode should be pruned; otherwise <c>false</c>.</returns> + internal static bool ShouldPrune(Episode episode, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials) + { + if (!episode.IsVirtualItem || !episode.HasProviderId(MetadataProvider.Tmdb)) + { + return false; + } + + // Specials are removed entirely unless the user opts in. + if (episode.ParentIndexNumber == 0 && !importSpecials) + { + return true; + } + + // When missing episodes are not wanted, prune placeholders for episodes that aired more than + // the grace period ago. + return pruneAgedOut + && episode.PremiereDate.HasValue + && episode.PremiereDate.Value.Date < today.AddDays(-gracePeriodDays); + } + + internal static DateTime? GetPremiereDate(TvSeasonEpisode tmdbEpisode) + { + return tmdbEpisode.AirDate.HasValue + ? DateTime.SpecifyKind(tmdbEpisode.AirDate.Value, DateTimeKind.Local).ToUniversalTime() + : null; + } + + internal static bool UpdateVirtualEpisode(Episode episode, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) + { + var changed = false; + + if (!string.IsNullOrEmpty(tmdbEpisode.Name) && !string.Equals(episode.Name, tmdbEpisode.Name, StringComparison.Ordinal)) + { + episode.Name = tmdbEpisode.Name; + changed = true; + } + + if (!string.IsNullOrEmpty(tmdbEpisode.Overview) && !string.Equals(episode.Overview, tmdbEpisode.Overview, StringComparison.Ordinal)) + { + episode.Overview = tmdbEpisode.Overview; + changed = true; + } + + if (premiereDate.HasValue && episode.PremiereDate != premiereDate) + { + episode.PremiereDate = premiereDate; + episode.ProductionYear = tmdbEpisode.AirDate?.Year; + changed = true; + } + + return changed; + } + + private Episode AddVirtualEpisode(Series series, Season season, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) + { + var seasonNumber = season.IndexNumber.GetValueOrDefault(); + var episodeNumber = (int)tmdbEpisode.EpisodeNumber; + + // Leaving Path unset makes the item a virtual (metadata-only) episode. + var episode = new Episode + { + Name = tmdbEpisode.Name, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + Id = _libraryManager.GetNewItemId( + series.Id.ToString("N", CultureInfo.InvariantCulture) + + "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture) + + "Episode" + episodeNumber.ToString(CultureInfo.InvariantCulture), + typeof(Episode)), + IsVirtualItem = true, + PremiereDate = premiereDate, + ProductionYear = tmdbEpisode.AirDate?.Year, + Overview = tmdbEpisode.Overview, + SeasonId = season.Id, + SeasonName = season.Name, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey() + }; + + episode.PresentationUniqueKey = episode.CreatePresentationUniqueKey(); + + if (tmdbEpisode.Id > 0) + { + episode.SetProviderId(MetadataProvider.Tmdb, tmdbEpisode.Id.ToString(CultureInfo.InvariantCulture)); + } + + _logger.LogInformation( + "Creating virtual episode S{SeasonNumber}E{EpisodeNumber} for series {SeriesName}", + seasonNumber, + episodeNumber, + series.Name); + + season.AddChild(episode); + + return episode; + } + + /// <summary> + /// Downloads the TMDb still for a virtual episode that has no image yet, so it does not fall back + /// to the season/series image. + /// </summary> + /// <param name="episode">The virtual episode.</param> + /// <param name="tmdbEpisode">The matching TMDb episode.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><c>true</c> if a still was downloaded and saved; otherwise <c>false</c>.</returns> + private async Task<bool> EnsureEpisodeImageAsync(Episode episode, TvSeasonEpisode tmdbEpisode, CancellationToken cancellationToken) + { + // The still ships with the season episode list, so use it directly instead of a per-episode lookup. + if (episode.HasImage(ImageType.Primary, 0) || string.IsNullOrEmpty(tmdbEpisode.StillPath)) + { + return false; + } + + var stillUrl = _tmdbClientManager.GetStillUrl(tmdbEpisode.StillPath); + if (string.IsNullOrEmpty(stillUrl)) + { + return false; + } + + try + { + // SaveImage sets the image path on the item but does not persist it, so save afterwards. + await _providerManager.SaveImage(episode, stillUrl, ImageType.Primary, null, cancellationToken).ConfigureAwait(false); + await episode.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, cancellationToken).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error downloading still for virtual episode S{SeasonNumber}E{EpisodeNumber} of {SeriesName}", + episode.ParentIndexNumber, + episode.IndexNumber, + episode.SeriesName); + return false; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 1eb522137d..9c41d64253 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -76,11 +76,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV result.Item.Name = seasonResult.Name; } + result.Item.TrySetProviderId(MetadataProvider.Tmdb, seasonResult.Id?.ToString(CultureInfo.InvariantCulture)); result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds?.TvdbId); - // TODO why was this disabled? var credits = seasonResult.Credits; - if (credits?.Cast is not null) { var castQuery = config.HideMissingCastMembers diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs new file mode 100644 index 0000000000..e2846c74a3 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV +{ + /// <summary> + /// Scheduled task that re-checks TMDb for newly announced unaired and missing episodes and creates + /// the corresponding virtual items. This keeps the "Upcoming" view current for series whose local + /// files have not changed, which an ordinary library scan would never re-examine. + /// </summary> + public class TmdbUpcomingEpisodesTask : IScheduledTask + { + private const int DefaultIntervalDays = 7; + + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private readonly ILogger<TmdbUpcomingEpisodesTask> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="TmdbUpcomingEpisodesTask"/> class. + /// </summary> + /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> + /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param> + /// <param name="logger">The <see cref="ILogger{TmdbUpcomingEpisodesTask}"/>.</param> + public TmdbUpcomingEpisodesTask( + ILibraryManager libraryManager, + IFileSystem fileSystem, + ILogger<TmdbUpcomingEpisodesTask> logger) + { + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "Refresh upcoming and missing episodes (TheMovieDb)"; + + /// <inheritdoc /> + public string Description => "Checks TheMovieDb for newly announced episodes and creates virtual entries for unaired and missing episodes, according to the TMDb plugin settings. When both options are disabled, removes any virtual entries previously created."; + + /// <inheritdoc /> + public string Category => "Library"; + + /// <inheritdoc /> + public string Key => "TmdbRefreshUpcomingEpisodes"; + + /// <inheritdoc /> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + var intervalDays = Plugin.Instance?.Configuration.MissingEpisodeRefreshIntervalDays ?? DefaultIntervalDays; + if (intervalDays <= 0) + { + intervalDays = DefaultIntervalDays; + } + + yield return new TaskTriggerInfo + { + Type = TaskTriggerInfoType.IntervalTrigger, + IntervalTicks = TimeSpan.FromDays(intervalDays).Ticks + }; + } + + /// <inheritdoc /> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + var configuration = Plugin.Instance?.Configuration; + if (configuration is null) + { + progress.Report(100); + return; + } + + // The feature is fully disabled: remove every virtual episode (and now-empty virtual season) + // this provider previously created, across all libraries, then stop. + if ((!configuration.ImportUnairedEpisodes && !configuration.ImportMissingEpisodes) + || configuration.EnabledMissingEpisodeLibraries.Length == 0) + { + RemoveAllVirtualItems(progress, cancellationToken); + return; + } + + // Process non-ended series (they may have gained episodes) plus any series in a library that + // is not opted in (regardless of status) so the provider can prune the virtual episodes it + // previously created there. Ended series in enabled libraries cannot change, so they're skipped. + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Series], + Recursive = true + }) + .OfType<Series>() + .Where(s => s.HasProviderId(MetadataProvider.Tmdb) + && (s.Status != SeriesStatus.Ended || !IsEnabledForLibrary(s))) + .ToList(); + + if (series.Count == 0) + { + progress.Report(100); + return; + } + + // ValidateChildren (rather than a bare RefreshMetadata) is required so the created episodes + // are immediately visible. + var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.Default, + ImageRefreshMode = MetadataRefreshMode.ValidationOnly, + IsAutomated = true + }; + + for (var i = 0; i < series.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await series[i].ValidateChildren(new Progress<double>(), refreshOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing upcoming episodes for series {SeriesName}", series[i].Name); + } + + progress.Report(100.0 * (i + 1) / series.Count); + } + } + + private bool IsEnabledForLibrary(BaseItem item) + { + var enabledLibraries = Plugin.Instance?.Configuration.EnabledMissingEpisodeLibraries; + if (enabledLibraries is null || enabledLibraries.Length == 0) + { + return false; + } + + // A series can live under more than one collection folder; opting in any one of them is + // enough. An item that belongs to no collection folder cannot be opted in at all. + return _libraryManager.GetCollectionFolders(item).Any(folder => + enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)); + } + + /// <summary> + /// Removes every virtual episode this provider created (identified by being virtual and carrying + /// a TMDb id), plus any virtual season left without episodes as a result. Used when both import + /// options are disabled so turning the feature off cleans up its placeholders. + /// </summary> + private void RemoveAllVirtualItems(IProgress<double> progress, CancellationToken cancellationToken) + { + var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; + + var virtualEpisodes = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Episode], + IsVirtualItem = true, + HasTmdbId = true, + Recursive = true + }); + + for (var i = 0; i < virtualEpisodes.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("Removing virtual episode {Name}: the TMDb missing episode provider is disabled", virtualEpisodes[i].Name); + _libraryManager.DeleteItem(virtualEpisodes[i], deleteOptions, false); + + progress.Report(95.0 * (i + 1) / virtualEpisodes.Count); + } + + // Remove virtual seasons that are now empty (mirrors the cleanup an ordinary series refresh does). + var virtualSeasons = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Season], + IsVirtualItem = true, + HasTmdbId = true, + Recursive = true + }); + + foreach (var season in virtualSeasons.OfType<Season>()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (season.GetEpisodes().Count == 0) + { + _libraryManager.DeleteItem(season, deleteOptions, false); + } + } + + progress.Report(100); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 174f1546a7..c8e3a7aa52 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -592,6 +592,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> + /// Gets the absolute URL of an episode still. + /// </summary> + /// <param name="stillPath">The relative URL of the still.</param> + /// <returns>The absolute URL.</returns> + public string? GetStillUrl(string? stillPath) + { + return GetUrl(Plugin.Instance.Configuration.StillSize, stillPath); + } + + /// <summary> /// Converts poster <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s. /// </summary> /// <param name="images">The input images.</param> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 88a2c684ff..bfd0fac34a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.MatchCriteria; @@ -13,6 +14,14 @@ namespace Jellyfin.Database.Implementations; public static class DescendantQueryHelper { /// <summary> + /// Gets the predicate identifying items that count toward played/total aggregation: + /// real leaf media, i.e. neither folders nor virtual items (missing or unaired episodes). + /// Shared by the per-item and batched count paths so they cannot diverge. + /// </summary> + public static Expression<Func<BaseItemEntity, bool>> IsCountableLeaf { get; } = + b => !b.IsFolder && !b.IsVirtualItem; + + /// <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/Entities/LinkedChildEntity.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs index 7361775711..be315f1b2c 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs @@ -25,7 +25,7 @@ public class LinkedChildEntity /// <summary> /// Gets or sets the sort order. /// </summary> - public int? SortOrder { get; set; } + public int SortOrder { get; set; } /// <summary> /// Gets or sets the parent item navigation property. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs index 2abccd41f0..b4013a394f 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs @@ -13,8 +13,7 @@ public class LinkedChildConfiguration : IEntityTypeConfiguration<LinkedChildEnti public void Configure(EntityTypeBuilder<LinkedChildEntity> builder) { builder.ToTable("LinkedChildren"); - builder.HasKey(e => new { e.ParentId, e.ChildId }); - builder.HasIndex(e => new { e.ParentId, e.SortOrder }); + builder.HasKey(e => new { e.ParentId, e.SortOrder }); builder.HasIndex(e => new { e.ParentId, e.ChildType }); builder.HasIndex(e => new { e.ChildId, e.ChildType }); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs index 32ede86c96..7ebdbf4e8b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs @@ -15,7 +15,7 @@ public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration<PeopleBas builder.HasKey(e => new { e.ItemId, e.PeopleId, e.Role }); builder.HasIndex(e => new { e.ItemId, e.SortOrder }); builder.HasIndex(e => new { e.ItemId, e.ListOrder }); - builder.HasIndex(e => e.PeopleId); + builder.HasIndex(e => new { e.PeopleId, e.ItemId }); builder.HasOne(e => e.Item); builder.HasOne(e => e.People); } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.Designer.cs new file mode 100644 index 0000000000..d5b6bd1d51 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.Designer.cs @@ -0,0 +1,1808 @@ +// <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("20260723111547_AllowDuplicatePlaylistChildren")] + partial class AllowDuplicatePlaylistChildren + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + 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"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + 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.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("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + 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<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + 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() + .HasFilter("[UserId] IS NOT NULL"); + + 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<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + 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() + .HasFilter("[UserId] IS NOT NULL"); + + 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); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + 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/20260723111547_AllowDuplicatePlaylistChildren.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.cs new file mode 100644 index 0000000000..173213034e --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// <inheritdoc /> + public partial class AllowDuplicatePlaylistChildren : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + // Rows that predate the composite (ParentId, SortOrder) primary key stored a null SortOrder + // (e.g. BoxSet and Collection children). Assign each such row a stable 0-based position within + // its parent so the rows stay unique once SortOrder becomes part of the primary key; otherwise + // they would all collapse to the column default (0) and collide during the table rebuild. + migrationBuilder.Sql( + @"UPDATE ""LinkedChildren"" + SET ""SortOrder"" = ( + SELECT COUNT(*) + FROM ""LinkedChildren"" AS lc2 + WHERE lc2.""ParentId"" = ""LinkedChildren"".""ParentId"" + AND lc2.""rowid"" < ""LinkedChildren"".""rowid"" + ) + WHERE ""SortOrder"" IS NULL;"); + + migrationBuilder.DropPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren"); + + migrationBuilder.DropIndex( + name: "IX_LinkedChildren_ParentId_SortOrder", + table: "LinkedChildren"); + + migrationBuilder.AlterColumn<int>( + name: "SortOrder", + table: "LinkedChildren", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AddPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren", + columns: new[] { "ParentId", "SortOrder" }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + // The (ParentId, ChildId) primary key cannot represent the same child more than once per + // parent. Drop any duplicate entries (keeping the first by SortOrder) that may have been + // created while duplicates were allowed, so the old key can be restored. This is lossy by + // nature — duplicate playlist entries cannot survive a downgrade. + migrationBuilder.Sql( + @"DELETE FROM ""LinkedChildren"" + WHERE ""rowid"" NOT IN ( + SELECT MIN(""rowid"") + FROM ""LinkedChildren"" + GROUP BY ""ParentId"", ""ChildId"" + );"); + + migrationBuilder.DropPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren"); + + migrationBuilder.AlterColumn<int>( + name: "SortOrder", + table: "LinkedChildren", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AddPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren", + columns: new[] { "ParentId", "ChildId" }); + + migrationBuilder.CreateIndex( + name: "IX_LinkedChildren_ParentId_SortOrder", + table: "LinkedChildren", + columns: new[] { "ParentId", "SortOrder" }); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.Designer.cs new file mode 100644 index 0000000000..b9a207f200 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.Designer.cs @@ -0,0 +1,2035 @@ +// <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.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260728170000_AddPeopleNameLowerIndex")] + partial class AddPeopleNameLowerIndex + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + 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"); + + 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"); + + 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<Guid>("ChildId") + .HasColumnType("TEXT"); + + b.Property<int>("ChildType") + .HasColumnType("INTEGER"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "ChildId"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.HasIndex("ParentId", "SortOrder"); + + 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.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("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + 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<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + 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() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PlaybackItem", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<Guid?>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("MediaType") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("PlaybackItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PlaybackItemKey", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("PlaybackItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("PlaybackItemId"); + + b.ToTable("PlaybackItemKeys"); + + 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<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + 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() + .HasFilter("[UserId] IS NOT NULL"); + + 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.UserPlaybackHistory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long?>("ActualBytesTransferred") + .HasColumnType("INTEGER"); + + b.Property<int?>("Bitrate") + .HasColumnType("INTEGER"); + + b.Property<string>("ClientName") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateStarted") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateStopped") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .HasColumnType("TEXT"); + + b.Property<string>("MediaSourceId") + .HasColumnType("TEXT"); + + b.Property<string>("PlaySessionId") + .HasColumnType("TEXT"); + + b.Property<Guid>("PlaybackItemId") + .HasColumnType("TEXT"); + + b.Property<long>("PlayedDurationTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("PlayedToCompletion") + .HasColumnType("INTEGER"); + + b.Property<long?>("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property<long>("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.Property<long>("StopPositionTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("Transcoded") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlaybackItemId", "PlayedToCompletion"); + + b.HasIndex("UserId", "DateStopped"); + + b.HasIndex("UserId", "PlaybackItemId", "DateStopped"); + + b.ToTable("UserPlaybackHistory"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserPlaybackHistoryStream", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<int?>("Bitrate") + .HasColumnType("INTEGER"); + + b.Property<int?>("Channels") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<Guid>("HistoryId") + .HasColumnType("TEXT"); + + b.Property<bool?>("IsForced") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property<string>("Language") + .HasColumnType("TEXT"); + + b.Property<int>("Origin") + .HasColumnType("INTEGER"); + + b.Property<int>("StreamType") + .HasColumnType("INTEGER"); + + b.Property<string>("VideoRange") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("HistoryId"); + + b.HasIndex("StreamType", "Origin", "Language"); + + b.HasIndex("StreamType", "Origin", "VideoRange"); + + b.ToTable("UserPlaybackHistoryStreams"); + + 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); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PlaybackItemKey", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.PlaybackItem", "PlaybackItem") + .WithMany("Keys") + .HasForeignKey("PlaybackItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("PlaybackItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + 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.UserPlaybackHistory", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.PlaybackItem", "PlaybackItem") + .WithMany("History") + .HasForeignKey("PlaybackItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("PlaybackItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserPlaybackHistoryStream", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.UserPlaybackHistory", "History") + .WithMany("Streams") + .HasForeignKey("HistoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("History"); + }); + + 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.PlaybackItem", b => + { + b.Navigation("History"); + + b.Navigation("Keys"); + }); + + 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"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserPlaybackHistory", b => + { + b.Navigation("Streams"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.cs new file mode 100644 index 0000000000..1f59630fe9 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class AddPeopleNameLowerIndex : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + // Expression index, so it cannot be declared on the entity type. /Persons collapses the + // one-row-per-(Name, PersonType) table to one row per lowercased name; without this index + // that dedup scans and groups the whole table on every request. + migrationBuilder.Sql("CREATE INDEX IF NOT EXISTS \"IX_Peoples_NameLower\" ON \"Peoples\" (lower(\"Name\"));"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DROP INDEX IF EXISTS \"IX_Peoples_NameLower\";"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728182152_AddPeopleItemMapCoveringIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728182152_AddPeopleItemMapCoveringIndex.Designer.cs new file mode 100644 index 0000000000..414210f444 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728182152_AddPeopleItemMapCoveringIndex.Designer.cs @@ -0,0 +1,1813 @@ +// <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("20260728182152_AddPeopleItemMapCoveringIndex")] + partial class AddPeopleItemMapCoveringIndex + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + 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"); + + 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"); + + 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<Guid>("ChildId") + .HasColumnType("TEXT"); + + b.Property<int>("ChildType") + .HasColumnType("INTEGER"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "ChildId"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.HasIndex("ParentId", "SortOrder"); + + 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.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<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + 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() + .HasFilter("[UserId] IS NOT NULL"); + + 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<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + 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() + .HasFilter("[UserId] IS NOT NULL"); + + 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); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + 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/20260728182152_AddPeopleItemMapCoveringIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728182152_AddPeopleItemMapCoveringIndex.cs new file mode 100644 index 0000000000..9be7953ada --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728182152_AddPeopleItemMapCoveringIndex.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// <inheritdoc /> + public partial class AddPeopleItemMapCoveringIndex : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PeopleBaseItemMap_PeopleId", + table: "PeopleBaseItemMap"); + + migrationBuilder.CreateIndex( + name: "IX_PeopleBaseItemMap_PeopleId_ItemId", + table: "PeopleBaseItemMap", + columns: new[] { "PeopleId", "ItemId" }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PeopleBaseItemMap_PeopleId_ItemId", + table: "PeopleBaseItemMap"); + + migrationBuilder.CreateIndex( + name: "IX_PeopleBaseItemMap_PeopleId", + table: "PeopleBaseItemMap", + column: "PeopleId"); + } + } +} 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 0916f72d9c..cdf5c84826 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -815,23 +815,21 @@ namespace Jellyfin.Server.Implementations.Migrations 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.Property<int?>("SortOrder") - .HasColumnType("INTEGER"); - - b.HasKey("ParentId", "ChildId"); + b.HasKey("ParentId", "SortOrder"); b.HasIndex("ChildId", "ChildType"); b.HasIndex("ParentId", "ChildType"); - b.HasIndex("ParentId", "SortOrder"); - b.ToTable("LinkedChildren", (string)null); b.HasAnnotation("Sqlite:UseSqlReturningClause", false); @@ -1060,12 +1058,12 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("ItemId", "PeopleId", "Role"); - b.HasIndex("PeopleId"); - b.HasIndex("ItemId", "ListOrder"); b.HasIndex("ItemId", "SortOrder"); + b.HasIndex("PeopleId", "ItemId"); + b.ToTable("PeopleBaseItemMap"); b.HasAnnotation("Sqlite:UseSqlReturningClause", false); diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index e421601092..ed02fe6a1d 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -14,7 +14,6 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; -using Jellyfin.LiveTv; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -1110,8 +1109,9 @@ namespace Jellyfin.LiveTv.Channels item.Path = mediaSource?.Path; } - if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, null, info.ImageUrl)) + if (!string.IsNullOrEmpty(info.ImageUrl) && !item.HasImage(ImageType.Primary)) { + item.SetImagePath(ImageType.Primary, info.ImageUrl); _logger.LogDebug("Forcing update due to ImageUrl {0}", item.Name); forceUpdate = true; } diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 4e1b62cdf9..41520f8789 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -5,7 +5,6 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; -using Jellyfin.LiveTv; using Jellyfin.LiveTv.Configuration; using Jellyfin.LiveTv.Listings; using MediaBrowser.Common.Configuration; @@ -450,9 +449,23 @@ public class GuideManager : IGuideManager item.Name = channelInfo.Name; - if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, channelInfo.ImagePath, channelInfo.ImageUrl)) + var currentPrimary = item.GetImageInfo(ImageType.Primary, 0); + var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl); + + // Update channel image if image URL has changed + if (currentPrimary is null + || (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal))) { - forceUpdate = true; + if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); + forceUpdate = true; + } + else if (!imageUrlIsNull) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); + forceUpdate = true; + } } if (isNew) diff --git a/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs b/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs deleted file mode 100644 index a590193b5f..0000000000 --- a/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs +++ /dev/null @@ -1,33 +0,0 @@ -using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Entities; - -namespace Jellyfin.LiveTv; - -/// <summary> -/// Helpers for keeping Live TV channel icons in sync with guide data. -/// </summary> -internal static class LiveTvChannelImageHelper -{ - /// <summary> - /// Applies the channel icon from guide or tuner metadata. - /// Called on each guide refresh so remote icons are re-downloaded even when the URL is unchanged. - /// </summary> - /// <param name="item">The channel item.</param> - /// <param name="imagePath">The local image path from the tuner, if any.</param> - /// <param name="imageUrl">The remote image URL from the guide provider, if any.</param> - /// <returns><c>true</c> when the item image metadata was updated.</returns> - internal static bool UpdateChannelImageIfNeeded(BaseItem item, string? imagePath, string? imageUrl) - { - var newImageSource = !string.IsNullOrWhiteSpace(imagePath) - ? imagePath - : imageUrl; - - if (string.IsNullOrWhiteSpace(newImageSource)) - { - return false; - } - - item.SetImagePath(ImageType.Primary, newImageSource); - return true; - } -} diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index 4559f68ce8..496c108cfd 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -491,6 +491,7 @@ public class NetworkManager : INetworkManager, IDisposable startupOverrideKey, true, true)); + WarnIfPublishedUrlBasePathDiffers(publishedServerUrls, config.BaseUrl); _publishedServerUrls = publishedServerUrls; return; } @@ -580,10 +581,53 @@ public class NetworkManager : INetworkManager, IDisposable } } + WarnIfPublishedUrlBasePathDiffers(publishedServerUrls, config.BaseUrl); _publishedServerUrls = publishedServerUrls; } } + /// <summary> + /// Warns when a full-URL published server override uses a public path that differs from the configured base + /// URL. Jellyfin appends the base URL to generated Live TV client URLs in this case, which can conflict with + /// reverse proxies that translate public request paths. Bare host/IP overrides are exempt because the base URL + /// is appended when the API URL is built from them. + /// </summary> + /// <param name="publishedServerUrls">The parsed published server URL overrides.</param> + /// <param name="baseUrl">The configured base URL, if any.</param> + private void WarnIfPublishedUrlBasePathDiffers(List<PublishedServerUriOverride> publishedServerUrls, string baseUrl) + { + if (string.IsNullOrEmpty(baseUrl)) + { + return; + } + + foreach (var overrideUri in publishedServerUrls.Select(x => x.OverrideUri).Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!overrideUri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !overrideUri.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!Uri.TryCreate(overrideUri, UriKind.Absolute, out var uri)) + { + continue; + } + + var path = Uri.UnescapeDataString(uri.AbsolutePath).TrimEnd('/'); + if (path.EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var publishedServerHost = uri.GetComponents(UriComponents.HostAndPort, UriFormat.Unescaped); + _logger.LogWarning( + "The published server URL for host '{PublishedServerHost}' does not end with the configured base URL '{BaseUrl}'. Jellyfin will append this base URL when generating Live TV client URLs. If your reverse proxy translates public paths, this may cause Live TV playback to fail. Update the Published Server URIs setting on the Networking page of the admin dashboard, the JELLYFIN_PublishedServerUrl environment variable / --published-server-url option, or the reverse proxy path mapping accordingly.", + publishedServerHost, + baseUrl); + } + } + private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt) { if (evt.Key.Equals(NetworkConfigurationStore.StoreKey, StringComparison.Ordinal)) @@ -851,7 +895,7 @@ public class NetworkManager : INetworkManager, IDisposable bool isExternal = !IsInLocalNetwork(source); _logger.LogDebug("Trying to get bind address for source {Source} - External: {IsExternal}", source, isExternal); - if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result)) + if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result, out port)) { return result; } @@ -1017,11 +1061,12 @@ public class NetworkManager : INetworkManager, IDisposable /// <param name="source">IP source address to use.</param> /// <param name="isInExternalSubnet">True if the source is in an external subnet.</param> /// <param name="bindPreference">The published server URL that matches the source address.</param> + /// <param name="port">The explicit port parsed from the override, if any.</param> /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns> - private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference) + private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference, out int? port) { bindPreference = string.Empty; - int? port = null; + port = null; // Only consider subnets including the source IP, preferring specific overrides List<PublishedServerUriOverride> validPublishedServerUrls; @@ -1063,25 +1108,43 @@ public class NetworkManager : INetworkManager, IDisposable return false; } - // Handle override specifying port - var parts = bindPreference.Split(':'); - if (parts.Length > 1) + // Handle override specifying an explicit port. + (bindPreference, port) = ParseHostAndPort(bindPreference); + + if (port.HasValue) { - if (int.TryParse(parts[1], out int p)) - { - bindPreference = parts[0]; - port = p; - _logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port); - return true; - } + _logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port); + } + else + { + _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference); } - - _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference); return true; } /// <summary> + /// Splits a published server URL override into its host and explicit port, if any. + /// Full URLs (containing "://") are returned whole, with any port left embedded. + /// </summary> + /// <param name="value">The override value, e.g. "host:port", "[::1]:port", or a full URL.</param> + /// <returns>The parsed host (or the original value if not split) and the explicit port, if any.</returns> + private static (string Host, int? Port) ParseHostAndPort(string value) + { + if (value.Contains("://", StringComparison.Ordinal)) + { + return (value, null); + } + + if (Uri.TryCreate("any://" + value, UriKind.Absolute, out var parsed) && parsed.Port != -1) + { + return (parsed.DnsSafeHost, parsed.Port); + } + + return (value, null); + } + + /// <summary> /// Attempts to match the source against the user defined bind interfaces. /// </summary> /// <param name="source">IP source address to use.</param> diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs index a003be4d96..fe824eddd9 100644 --- a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs +++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs @@ -1,13 +1,21 @@ using System; using System.Globalization; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Api.Helpers; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common.Net; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using MediaBrowser.Model.MediaInfo; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Moq; using Xunit; @@ -16,17 +24,28 @@ namespace Jellyfin.Api.Tests.Helpers { public class MediaInfoHelperTests { - private static MediaInfoHelper CreateHelper() + private const string LiveStreamFilesPath = "/LiveTv/LiveStreamFiles/abc/stream.ts"; + + private static MediaInfoHelper CreateHelper( + IMediaSourceManager? mediaSourceManager = null, + IServerApplicationHost? appHost = null, + string baseUrl = "") { + var serverConfigurationManager = new Mock<IServerConfigurationManager>(); + serverConfigurationManager + .Setup(x => x.GetConfiguration(It.IsAny<string>())) + .Returns(new NetworkConfiguration { BaseUrl = baseUrl }); + return new MediaInfoHelper( Mock.Of<IUserManager>(), Mock.Of<ILibraryManager>(), - Mock.Of<IMediaSourceManager>(), + mediaSourceManager ?? Mock.Of<IMediaSourceManager>(), Mock.Of<IMediaEncoder>(), - Mock.Of<IServerConfigurationManager>(), + serverConfigurationManager.Object, Mock.Of<ILogger<MediaInfoHelper>>(), Mock.Of<INetworkManager>(), - Mock.Of<IDeviceManager>()); + Mock.Of<IDeviceManager>(), + appHost ?? Mock.Of<IServerApplicationHost>()); } private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true) @@ -95,5 +114,403 @@ namespace Jellyfin.Api.Tests.Helpers Assert.Equal(directPlay.Id, result.MediaSources[0].Id); } + + [Fact] + public async Task GetPlaybackInfo_ExistingLiveStream_RewritesReturnedCloneOnly() + { + const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath; + + var sharedLiveSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.GetLiveStream(It.IsAny<string>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(sharedLiveSource); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://media.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var result = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of<HttpRequest>(), liveStreamId: "live-1").ConfigureAwait(true); + + Assert.Equal("https://media.example.com" + LiveStreamFilesPath, result.MediaSources[0].Path); + + // The shared instance handed back by GetLiveStream must remain untouched; only the clone in the response may be rewritten. + Assert.Equal(LocalPath, sharedLiveSource.Path); + } + + [Fact] + public async Task OpenMediaSource_RewritesReturnedLiveStreamPath() + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://127.0.0.1:8096" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://public.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://public.example.com" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_ExternalDockerBridgeBehindReverseProxy_UsesPublishedUrl() + { + const string LocalPath = "http://172.23.0.5:8096" + LiveStreamFilesPath; + + // Represents the instance MediaSourceManager keeps for its own bookkeeping; the helper never sees it + // and must not be able to affect it. + var localSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + // Mirrors production: MediaSourceManager.OpenLiveStream hands back its own instance, so what the + // helper mutates must be a deserialized copy, never localSource itself. + var clone = JsonSerializer.Deserialize<MediaSourceInfo>(JsonSerializer.SerializeToUtf8Bytes(localSource))!; + return new LiveStreamResponse(clone); + }); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://jellyfin.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://jellyfin.example.com" + LiveStreamFilesPath, response.MediaSource.Path); + + // The mock now actually derives its response from localSource, so this assertion is meaningful: + // rewriting the returned clone must never mutate the object localSource represents. + Assert.Equal(LocalPath, localSource.Path); + } + + [Fact] + public async Task OpenMediaSource_ForeignHostWithLiveStreamFilesRoute_PathUnchanged() + { + // A plugin or remote source can expose a path that happens to match the /LiveTv/LiveStreamFiles/ + // route shape without actually being hosted by this server. Only opened streams (which always + // carry a LiveStreamId) are eligible for rewriting. + const string ForeignPath = "https://other-server:8096" + LiveStreamFilesPath; + + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = ForeignPath + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(ForeignPath, response.MediaSource.Path); + } + + [Theory] + [InlineData(MediaProtocol.Http, "http://192.168.1.50:5004/live/channel1.ts")] + [InlineData(MediaProtocol.File, "/media/livetv/buffer/abc/stream.ts")] + [InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/Videos/abc/stream.ts")] + [InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/library/movie.strm")] + public async Task OpenMediaSource_NotAPublishableLiveStreamFilesPath_PathUnchanged(MediaProtocol protocol, string path) + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = protocol, + Path = path + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(path, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_BaseUrlConfigured_RewritesWithBaseUrlPrefix() + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://media.example.com/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_BaseUrlSegmentMismatch_PathUnchanged() + { + const string LocalPath = "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath; + + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(LocalPath, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_ExplicitPortOverrideWithBaseUrl_RewritesToOverrideHostAndPort() + { + // Mirrors NetworkManager.GetBindAddress resolving a "internal=myhost:8097" override: the smart API + // URL carries an explicit non-default port alongside the configured BaseUrl. + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "http://myhost:8097/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("http://myhost:8097/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task GetPlaybackInfo_TwoRequestsForSharedLiveStream_ReceiveIndependentSmartApiBases() + { + const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath; + + // Both requests resolve the same live stream; the manager hands back its own shared instance each time. + var sharedLiveSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.GetLiveStream(It.IsAny<string>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(sharedLiveSource); + + var requestA = new DefaultHttpContext().Request; + var requestB = new DefaultHttpContext().Request; + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(requestA)).Returns("https://a.example.com"); + appHost.Setup(x => x.GetSmartApiUrl(requestB)).Returns("https://b.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var resultA = await helper.GetPlaybackInfo(new Movie(), null, requestA, liveStreamId: "live-1").ConfigureAwait(true); + var resultB = await helper.GetPlaybackInfo(new Movie(), null, requestB, liveStreamId: "live-1").ConfigureAwait(true); + + Assert.Equal("https://a.example.com" + LiveStreamFilesPath, resultA.MediaSources[0].Path); + Assert.Equal("https://b.example.com" + LiveStreamFilesPath, resultB.MediaSources[0].Path); + + // Neither request's rewrite may leak into the other's response or into the shared instance. + Assert.NotEqual(resultA.MediaSources[0].Path, resultB.MediaSources[0].Path); + Assert.Equal(LocalPath, sharedLiveSource.Path); + } + + [Fact] + public async Task GetPlaybackInfo_AutoOpenLiveStreamFlow_MergedOpenedSourceHasRewrittenPath() + { + // Reproduces MediaInfoController.GetPostedPlaybackInfo's AutoOpenLiveStream branch (~line 220-246): + // it picks the RequiresOpening source out of GetPlaybackInfo's result, calls OpenMediaSource, then + // merges by replacing result.MediaSources with the opened source. Building a full controller fixture + // is impractical (it pulls in many unrelated dependencies), so this test drives the same two helper + // calls the controller makes and asserts the merged source is the rewritten one. + var itemId = Guid.NewGuid(); + var sourceId = itemId.ToString("N", CultureInfo.InvariantCulture); + + // The pre-open placeholder source carries a different local path than the one OpenMediaSource + // eventually returns, so the final assertion can prove the merge picked up the freshly opened + // source rather than the stale placeholder. + var requiresOpeningSource = new MediaSourceInfo + { + Id = sourceId, + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/LiveTv/LiveStreamFiles/placeholder/stream.ts", + RequiresOpening = true, + LiveStreamId = string.Empty + }; + + var openedSource = new MediaSourceInfo + { + Id = sourceId, + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.GetPlaybackMediaSources(It.IsAny<BaseItem>(), It.IsAny<User>(), true, true, It.IsAny<CancellationToken>())) + .ReturnsAsync(new[] { requiresOpeningSource }); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + // MediaSourceManager.OpenLiveStream JSON-clones its internal MediaSourceInfo before returning + // it (see Emby.Server.Implementations/Library/MediaSourceManager.cs:693-706); mirror that so + // the in-place rewrite below can't be observed on openedSource itself. + var clone = JsonSerializer.Deserialize<MediaSourceInfo>(JsonSerializer.SerializeToUtf8Bytes(openedSource))!; + return new LiveStreamResponse(clone); + }); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://media.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var info = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of<HttpRequest>()).ConfigureAwait(true); + + var mediaSource = info.MediaSources[0]; + Assert.True(mediaSource.RequiresOpening); + var preOpenPath = mediaSource.Path; + + var openStreamResult = await helper.OpenMediaSource( + new DefaultHttpContext(), + new LiveStreamRequest { OpenToken = mediaSource.OpenToken, ItemId = itemId }).ConfigureAwait(true); + + // MediaInfoController.cs:245 - info.MediaSources = new[] { openStreamResult.MediaSource }; + info.MediaSources = new[] { openStreamResult.MediaSource }; + + Assert.Equal("https://media.example.com" + LiveStreamFilesPath, info.MediaSources[0].Path); + Assert.NotEqual(preOpenPath, info.MediaSources[0].Path); + + // The pristine OpenLiveStream response object must remain unrewritten; only the merged clone changed. + Assert.Equal("http://172.19.0.3:8096" + LiveStreamFilesPath, openedSource.Path); + } + + [Theory] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/", + "http://172.19.0.3:8096" + LiveStreamFilesPath + "?token=1", + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath + "?token=1")] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096" + LiveStreamFilesPath + "#fragment", + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "https://172.19.0.3:8920" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://192.168.1.10:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com:8920", + "http://172.19.0.3:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com:8920" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://jellyfin", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://jellyfin/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/jellyfin", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/jellyfin/", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + null)] + [InlineData( + "https://media.example.com", + "/media/livetv/buffer/abc/stream.ts", + MediaProtocol.File, + "", + null)] + [InlineData( + "https://media.example.com", + "not a uri", + MediaProtocol.Http, + "", + null)] + public void GetPublishedLiveStreamPath_VariousInputs_ReturnsExpected(string smartApiUrl, string localPath, MediaProtocol protocol, string baseUrl, string? expected) + { + var result = MediaInfoHelper.GetPublishedLiveStreamPath(smartApiUrl, localPath, protocol, baseUrl); + + Assert.Equal(expected, result); + } + + private static MediaInfoHelper CreateOpenMediaSourceHelper(MediaSourceInfo mediaSource, string smartApiUrl, string baseUrl = "") + { + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new LiveStreamResponse(mediaSource)); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns(smartApiUrl); + + return CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object, baseUrl: baseUrl); + } } } diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index 258cf326ca..e34eb0bda3 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -1,19 +1,25 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Reflection; using System.Threading; +using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; 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.LiveTv; using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Querying; using Moq; using Xunit; @@ -293,6 +299,85 @@ public class BaseItemTests Times.Never); } + [Theory] + // A version file the scan just found beside the episode is not linked yet, so it does not count + // towards MediaSourceCount. The episode still has to refresh its owned items, as that is what + // creates the item for the version and links it. + [InlineData(true, false, true)] + [InlineData(false, true, true)] + [InlineData(false, false, false)] + public void SupportsOwnedItems_EpisodeWithResolvedVersionOrPart_IsTrue(bool hasLocalVersion, bool isStacked, bool expected) + { + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>()); + BaseItem.LibraryManager = libraryManager.Object; + + var episode = new Episode + { + Id = Guid.NewGuid(), + Path = "/TV/Show/Season 1/S01E01 - 1080p.mkv", + LocalAlternateVersions = hasLocalVersion ? ["/TV/Show/Season 1/S01E01 - 720p.mkv"] : [], + AdditionalParts = isStacked ? ["/TV/Show/Season 1/S01E01 - 1080p-part2.mkv"] : [] + }; + + var property = typeof(Episode).GetProperty("SupportsOwnedItems", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(property); + + Assert.Equal(expected, (bool)property!.GetValue(episode)!); + } + + [Theory] + // The season folder is the season's own, so the extras that sit in it are the season's. Whether + // the season holds one episode or two must not decide where its extras show up. + [InlineData(false, false)] + // An episode with a folder of its own keeps the extras in it, as nothing else searches there + [InlineData(true, true)] + public async Task RefreshedOwnedItems_EpisodeInAContainersOwnFolder_LeavesExtrasToTheContainer(bool episodeHasOwnFolder, bool expectSearch) + { + var seasonPath = Path.Combine("TV", "Show", "Season 1"); + var episodeFolder = episodeHasOwnFolder ? Path.Combine(seasonPath, "S01E01") : seasonPath; + var episodePath = Path.Combine(episodeFolder, "S01E01 - 1080p.mkv"); + + // The season needs a parent of its own, as an item without one maintains no owned items + var season = new Season { Id = Guid.NewGuid(), ParentId = Guid.NewGuid(), Path = seasonPath }; + var episode = new Episode + { + Id = Guid.NewGuid(), + ParentId = season.Id, + Path = episodePath, + // A version file is what makes an episode maintain owned items at all + LocalAlternateVersions = [Path.Combine(episodeFolder, "S01E01 - 720p.mkv")] + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + BaseItem.MediaSourceManager = mediaSourceManager.Object; + + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(x => x.FileExists(It.IsAny<string>())).Returns(true); + BaseItem.FileSystem = fileSystem.Object; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetItemById(season.Id)).Returns(season); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())).Returns(Array.Empty<BaseItem>()); + libraryManager.Setup(x => x.FindExtras(It.IsAny<BaseItem>(), It.IsAny<IReadOnlyList<FileSystemMetadata>>(), It.IsAny<IDirectoryService>())) + .Returns(Array.Empty<BaseItem>()); + BaseItem.LibraryManager = libraryManager.Object; + + var method = typeof(BaseItem).GetMethod("RefreshedOwnedItems", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var options = new MetadataRefreshOptions(Mock.Of<IDirectoryService>()); + await (Task<bool>)method!.Invoke(episode, [options, Array.Empty<FileSystemMetadata>(), CancellationToken.None])!; + + libraryManager.Verify( + x => x.FindExtras(episode, It.IsAny<IReadOnlyList<FileSystemMetadata>>(), It.IsAny<IDirectoryService>()), + expectSearch ? Times.Once() : Times.Never()); + } + private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup() { var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" }; @@ -443,4 +528,68 @@ public class BaseItemTests Assert.Equal(1982, trailer.ProductionYear); Assert.Equal(new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc), trailer.PremiereDate); } + + [Theory] + // An extra named after a version belongs to that version, not to the primary whose name it + // also starts with + [InlineData("/Movies/Movie/Movie - 4K-trailer.mkv", 2)] + [InlineData("/Movies/Movie/Movie - 1080p-behindthescenes.mkv", 1)] + // Named after the movie rather than one of its versions + [InlineData("/Movies/Movie/Movie-trailer.mkv", 0)] + // In an extras folder, so named after nothing in particular + [InlineData("/Movies/Movie/trailers/Official.mkv", 0)] + // A version name is only a match when it is followed by the extra's own suffix + [InlineData("/Movies/Movie/Movie - 4Kish-trailer.mkv", 0)] + public void GetOwnerIdForExtra_AssignsExtraToItsVersion(string extraPath, int expectedVersion) + { + var (primary, alt1, alt2) = SetupVersionGroup(); + var expectedId = expectedVersion switch + { + 1 => alt1.Id, + 2 => alt2.Id, + _ => primary.Id + }; + + var method = typeof(Video).GetMethod("GetOwnerIdForExtra", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var ownerId = (Guid)method!.Invoke(primary, [new Video { Id = Guid.NewGuid(), Path = extraPath }])!; + + Assert.Equal(expectedId, ownerId); + } + + [Fact] + public void GetExtraOwnerIds_FromAnyVersion_CoversEveryVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + var method = typeof(Video).GetMethod("GetExtraOwnerIds", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // An extra is owned by the one version it is named after, and the extras of the movie as a + // whole are owned by the primary, so every version has to read all of them back + foreach (var version in new[] { primary, alt1, alt2 }) + { + var ids = (Guid[])method!.Invoke(version, null)!; + + Assert.Equal(3, ids.Length); + Assert.Contains(primary.Id, ids); + Assert.Contains(alt1.Id, ids); + Assert.Contains(alt2.Id, ids); + } + } + + [Fact] + public void GetOwnedVersionIds_CoversEveryLocalVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + var method = typeof(Video).GetMethod("GetOwnedVersionIds", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // The extras of all versions are maintained together, so all of them have to be read back + var ids = (Guid[])method!.Invoke(primary, null)!; + + Assert.Equal([primary.Id, alt1.Id, alt2.Id], ids); + } } diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs index 71b6551d0f..2b009b4673 100644 --- a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs @@ -223,12 +223,51 @@ public class EncodingHelperTests Assert.Contains("-ar " + expectedSampleRate, args, StringComparison.Ordinal); } - private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate) + [Theory] + [InlineData("wav")] + [InlineData("flac")] + [InlineData("mp3")] + public void GetProgressiveAudioFullCommandLine_PcmInRealContainer_KeepsContainerMuxer(string outputContainer) + { + // A pcm_* encoder must not drag the raw muxer into a container that writes its own header, + // or the client gets headerless PCM behind the container's content type. + var state = BuildAudioState("pcm_s16le", 48000, outputContainer); + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out"); + + Assert.DoesNotContain("-f s16le", args, StringComparison.Ordinal); + } + + [Fact] + public void GetProgressiveAudioFullCommandLine_PcmInPcmContainer_ForcesRawMuxer() + { + // The raw-PCM route added in #10321 for I2S/MCU clients must keep working. + var state = BuildAudioState("pcm_s16le", 48000, "pcm"); + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out"); + + Assert.Contains("-f s16le", args, StringComparison.Ordinal); + } + + [Fact] + public void GetProgressiveAudioFullCommandLine_PcmWithoutBitrate_EmitsNoEmptySampleRate() + { + // AudioBitRate is optional; it used to be emitted as `-ar <null>`, producing a bare `-ar` + // that made ffmpeg abort with "Expected number for ar" and the request fail with HTTP 500. + var state = BuildAudioState("pcm_s16le", 48000, "wav"); + state.BaseRequest.AudioBitRate = null; + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out"); + + Assert.DoesNotContain("-ar -", args, StringComparison.Ordinal); + Assert.DoesNotContain("-ar ", args, StringComparison.Ordinal); + Assert.Contains("-ar 48000", 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 }; return new EncodingJobInfo(TranscodingJobType.Progressive) { + OutputContainer = outputContainer, MediaSource = new MediaSourceInfo { Container = "flac", diff --git a/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs deleted file mode 100644 index f44cb88834..0000000000 --- a/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Jellyfin.LiveTv; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Entities; -using Xunit; - -namespace Jellyfin.LiveTv.Tests; - -public class LiveTvChannelImageHelperTests -{ - [Fact] - public void UpdateChannelImageIfNeeded_NoSource_DoesNotUpdate() - { - var channel = new LiveTvChannel { Name = "Test Channel" }; - - var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, null); - - Assert.False(updated); - Assert.False(channel.HasImage(ImageType.Primary)); - } - - [Fact] - public void UpdateChannelImageIfNeeded_WithUrl_AppliesUrl() - { - var channel = new LiveTvChannel { Name = "Test Channel" }; - - var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded( - channel, - null, - "https://example.com/icon.png"); - - Assert.True(updated); - Assert.True(channel.HasImage(ImageType.Primary)); - Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary)); - } - - [Fact] - public void UpdateChannelImageIfNeeded_SameUrl_StillUpdates() - { - var channel = new LiveTvChannel { Name = "Test Channel" }; - LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, "https://example.com/icon.png"); - - var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded( - channel, - null, - "https://example.com/icon.png"); - - Assert.True(updated); - Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary)); - } -} diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index 5ba061296a..f5a023686c 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -371,6 +371,45 @@ namespace Jellyfin.Model.Tests Assert.Equal(streamInfo?.SubtitleStreamIndex, options.SubtitleStreamIndex); } + [Theory] + [InlineData("pgssub", null)] + [InlineData("vobsub", "mks")] + public async Task BuildVideoItemWithSecondaryAudioAndExternalGraphicalSubtitleKeepsVideoCopy(string subtitleCodec, string? subtitleContainer) + { + var options = await GetMediaOptions("Chrome", "mp4-h264-ac3-aac-srt-2600k"); + var subtitleStream = options.MediaSources[0].MediaStreams[^1]; + subtitleStream.Codec = subtitleCodec; + subtitleStream.IsExternal = false; + subtitleStream.SupportsExternalStream = true; + subtitleStream.Path = null; + + options.Profile.SubtitleProfiles = + [ + new SubtitleProfile + { + Format = subtitleCodec, + Container = subtitleContainer, + Method = SubtitleDeliveryMethod.External + } + ]; + options.AudioStreamIndex = 2; + options.SubtitleStreamIndex = subtitleStream.Index; + + var streamInfo = GetStreamBuilder(enableSubtitleExtraction: false).GetOptimalVideoStream(options); + + Assert.NotNull(streamInfo); + Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod); + Assert.Equal(TranscodeReason.SecondaryAudioNotSupported, streamInfo.TranscodeReasons); + Assert.Equal(SubtitleDeliveryMethod.External, streamInfo.SubtitleDeliveryMethod); + Assert.Contains("h264", streamInfo.VideoCodecs); + Assert.Contains("aac", streamInfo.AudioCodecs); + + var queryString = streamInfo.ToUrl("media:", "ACCESSTOKEN", null).Split('?', 2).ElementAtOrDefault(1); + var query = System.Web.HttpUtility.ParseQueryString(queryString ?? string.Empty); + Assert.Null(query["SubtitleStreamIndex"]); + Assert.Null(query["SubtitleMethod"]); + } + private StreamInfo? BuildVideoItemSimpleTest(MediaOptions options, PlayMethod? playMethod, TranscodeReason why, string transcodeMode, string transcodeProtocol) { if (string.IsNullOrEmpty(transcodeProtocol)) @@ -573,9 +612,10 @@ namespace Jellyfin.Model.Tests throw new SerializationException("Invalid test data: " + name); } - private StreamBuilder GetStreamBuilder() + private StreamBuilder GetStreamBuilder(bool enableSubtitleExtraction = false) { var transcodeSupport = new Mock<ITranscoderSupport>(); + transcodeSupport.Setup(t => t.CanExtractSubtitles(It.IsAny<string>())).Returns(enableSubtitleExtraction); var logger = new NullLogger<StreamBuilderTests>(); return new StreamBuilder(transcodeSupport.Object, logger); @@ -625,7 +665,7 @@ namespace Jellyfin.Model.Tests // EnableSubtitleExtraction = false, internal subtitles [InlineData("srt", "srt", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)] [InlineData("srt", "srt", false, false, PlayMethod.DirectPlay, SubtitleDeliveryMethod.External)] - [InlineData("pgssub", "pgssub", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)] + [InlineData("pgssub", "pgssub", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.External)] [InlineData("pgssub", "pgssub", false, false, PlayMethod.DirectPlay, SubtitleDeliveryMethod.External)] [InlineData("pgssub", "srt", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)] // EnableSubtitleExtraction = false, external subtitles @@ -678,7 +718,7 @@ namespace Jellyfin.Model.Tests [Theory] [InlineData(false, null, true, SubtitleDeliveryMethod.External)] - [InlineData(false, null, false, SubtitleDeliveryMethod.Encode)] + [InlineData(false, null, false, SubtitleDeliveryMethod.External)] [InlineData(true, "/media/sub.mks", true, SubtitleDeliveryMethod.External)] [InlineData(true, "/media/sub.idx", true, SubtitleDeliveryMethod.Encode)] [InlineData(true, "/media/sub.sub", true, SubtitleDeliveryMethod.Encode)] diff --git a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs index d42bd66a1c..0e35071dd4 100644 --- a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs @@ -20,6 +20,27 @@ namespace Jellyfin.Naming.Tests.Video } [Fact] + public void TestFormat3DAtEndOfPath() + { + // Directory based media (eg. DVD or BluRay folder rips) have no file extension, + // so the 3D tag can be the last token of the path. + Test("Super movie (2009) 3d hsbs", true, "hsbs"); + Test("Super movie (2009).3d.sbs", true, "sbs"); + Test("Super movie (2009) 3d htab", true, "htab"); + Test("Super movie (2009).hsbs", true, "hsbs"); + Test("Super movie (2009) 3d", false, null); + } + + [Fact] + public void TestResolveDirectory3D() + { + var result = VideoResolver.ResolveDirectory("/movies/Oblivion (2013) 3d hsbs", _namingOptions); + + Assert.True(result?.Is3D); + Assert.Equal("hsbs", result?.Format3D, true); + } + + [Fact] public void Test3DName() { var result = VideoResolver.ResolveFile("C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv", _namingOptions); diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 1f523f7f21..d8cb9e1ac6 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -6,6 +6,7 @@ using Jellyfin.Networking.Manager; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -493,5 +494,219 @@ namespace Jellyfin.Networking.Tests Assert.Equal(result, interfaceToUse); } + + [Theory] + // Internal override with an explicit port. + [InlineData("192.168.1.1", "192.168.1.0/24=internal.jellyfin:8097", "internal.jellyfin", 8097)] + // External/all override with an explicit port. + [InlineData("8.8.8.8", "all=external.jellyfin:8097", "external.jellyfin", 8097)] + // Bracketed IPv6 override with an explicit port. + [InlineData("8.8.8.8", "all=[fd00:1234::1]:8097", "fd00:1234::1", 8097)] + // Bare IPv6 override without a port - must remain whole, not mangled by the extra colons. + [InlineData("8.8.8.8", "all=fd00:1234::1", "fd00:1234::1", null)] + // Full HTTPS URL override with an explicit port - the URL stays whole, port stays embedded. + [InlineData("8.8.8.8", "all=https://secure.jellyfin.org:8920", "https://secure.jellyfin.org:8920", null)] + // Hostname beginning with "http" is a hostname, not a URL scheme. + [InlineData("8.8.8.8", "all=http-proxy.lan:8097", "http-proxy.lan", 8097)] + // Literal "internal" keyword override (applies to every LAN subnet) with an explicit port. + [InlineData("192.168.1.1", "internal=myhost.internal:8097", "myhost.internal", 8097)] + // Literal "external" keyword override with an explicit port. + [InlineData("8.8.8.8", "external=myhost.external:9090", "myhost.external", 9090)] + public void GetBindAddress_PublishedServerOverride_ParsesHostAndPort(string source, string publishedServers, string expectedHost, int? expectedPort) + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { publishedServers } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var intf = nm.GetBindAddress(IPAddress.Parse(source), out int? port); + + Assert.Equal(expectedHost, intf); + Assert.Equal(expectedPort, port); + } + + /// <summary> + /// Regression coverage for <c>IServerApplicationHost.GetApiUrlForLocalAccess()</c>, which calls + /// <see cref="NetworkManager.GetBindAddress(IPAddress, out int?, bool)"/> with a null source address. + /// Published server URL overrides are only matched when a source address is supplied + /// (<c>MatchesPublishedServerUrl</c> requires it), so a null source must never come back as a published + /// CLI/dashboard URL - it must fall back to a plain local bind address. + /// </summary> + [Fact] + public void GetBindAddress_NullSource_DoesNotApplyPublishedServerOverride() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { "all=http://published.example.com" } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var result = nm.GetBindAddress((IPAddress?)null, out var port); + + Assert.Equal("192.168.1.208", result); + Assert.Null(port); + } + + [Theory] + // Full-URL override with a different public path: warn about the Live TV fallback. + [InlineData("all=https://media.example.com", "/jellyfin", true)] + // Full-URL override that ends with the base URL (with and without a trailing slash): no warning. + [InlineData("all=https://media.example.com/jellyfin", "/jellyfin", false)] + [InlineData("all=https://media.example.com/jellyfin/", "/jellyfin", false)] + [InlineData("all=https://media.example.com/media/jellyfin", "/jellyfin", false)] + [InlineData("all=https://media.example.com/cool%20server", "/cool server", false)] + // A similar segment or a path following the base URL is a different public API base. + [InlineData("all=https://media.example.com/jellyfinx", "/jellyfin", true)] + [InlineData("all=https://media.example.com/jellyfin/media", "/jellyfin", true)] + // No base URL configured: there is no path to compare. + [InlineData("all=https://media.example.com", "", false)] + // Bare host overrides get the base URL appended when the API URL is built: no warning. + [InlineData("all=media.example.com", "/jellyfin", false)] + [InlineData("internal=http-proxy.lan:8097", "/jellyfin", false)] + // Keyword overrides go through the same check as "all". + [InlineData("internal=http://10.0.0.5:8096", "/jellyfin", true)] + public void InitializeOverrides_FullUrlPublicPathDiffersFromBaseUrl_LogsWarning(string publishedServers, string baseUrl, bool expectWarning) + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { publishedServers }, + BaseUrl = baseUrl + }; + + var logger = new Mock<ILogger<NetworkManager>>(); + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, logger.Object); + NetworkManager.MockNetworkSettings = string.Empty; + + VerifyBaseUrlWarning(logger, expectWarning ? Times.AtLeastOnce() : Times.Never()); + } + + /// <summary> + /// The JELLYFIN_PublishedServerUrl environment variable / --published-server-url option takes the + /// startup-configuration branch of <c>InitializeOverrides</c> and must funnel through the same + /// base URL check as the dashboard overrides. + /// </summary> + [Fact] + public void InitializeOverrides_StartupPublishedServerUrlPathDiffersFromBaseUrl_LogsWarningWithoutCredentials() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16" }, + EnableIPv4 = true, + BaseUrl = "/jellyfin" + }; + + var logger = new Mock<ILogger<NetworkManager>>(); + var startupConf = new Mock<IConfiguration>(); + startupConf.Setup(x => x[MediaBrowser.Controller.Extensions.ConfigurationExtensions.AddressOverrideKey]).Returns("https://user:password@media.example.com?access_token=secret#fragment"); + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16"; + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, logger.Object); + NetworkManager.MockNetworkSettings = string.Empty; + + VerifyBaseUrlWarning(logger, Times.AtLeastOnce()); + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("user", StringComparison.Ordinal) + || state.ToString()!.Contains("password", StringComparison.Ordinal) + || state.ToString()!.Contains("access_token", StringComparison.Ordinal) + || state.ToString()!.Contains("secret", StringComparison.Ordinal) + || state.ToString()!.Contains("fragment", StringComparison.Ordinal)), + It.IsAny<Exception?>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never()); + } + + private static void VerifyBaseUrlWarning(Mock<ILogger<NetworkManager>> logger, Times times) + { + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("Jellyfin will append this base URL when generating Live TV client URLs", StringComparison.Ordinal)), + It.IsAny<Exception?>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + times); + } + + /// <summary> + /// <see cref="NetworkManager.GetBindAddress(HttpRequest, out int?)"/> is the piece of request-host + /// normalization that a request-host-aware smart API URL policy relies on: it resolves the bind address + /// from the request's host and falls back to the request's own port when no override applies. + /// </summary> + [Fact] + public void GetBindAddress_HttpRequestOverload_FallsBackToRequestPortWhenNoOverride() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("192.168.1.1", 34567); + + var result = nm.GetBindAddress(httpContext.Request, out var port); + + Assert.Equal("192.168.1.208", result); + Assert.Equal(34567, port); + } + + /// <summary> + /// Ordering check: a dashboard published-server-URL override's explicit port takes precedence over the + /// request's own port, even though the request's host chose which override subnet matched. + /// </summary> + [Fact] + public void GetBindAddress_HttpRequestOverload_PublishedOverridePortWinsOverRequestPort() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { "internal=myhost.internal:9000" } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("192.168.1.1", 34567); + + var result = nm.GetBindAddress(httpContext.Request, out var port); + + Assert.Equal("myhost.internal", result); + Assert.Equal(9000, port); + } } } diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs new file mode 100644 index 0000000000..7813013c05 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs @@ -0,0 +1,275 @@ +using System; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.Tmdb.TV; +using TMDbLib.Objects.Search; +using Xunit; + +namespace Jellyfin.Providers.Tests.Tmdb; + +public class TmdbMissingEpisodeProviderTests +{ + private static readonly DateTime _today = new(2026, 6, 20, 0, 0, 0, DateTimeKind.Utc); + + [Theory] + // No air date -> never imported, regardless of options. + [InlineData(null, true, true, false, false, false)] + [InlineData(null, false, false, false, false, false)] + // Future (unaired) episodes are gated by the unaired option. + [InlineData(5, true, false, false, false, true)] + [InlineData(5, false, false, false, false, false)] + [InlineData(5, false, true, false, false, false)] + // Today counts as unaired. + [InlineData(0, true, false, false, false, true)] + [InlineData(0, false, false, false, false, false)] + // Past (already aired) episodes are gated by the missing option. + [InlineData(-5, false, true, false, false, true)] + [InlineData(-5, false, false, false, false, false)] + [InlineData(-5, true, false, false, false, false)] + // Specials are never imported when the specials option is off, regardless of air date. + [InlineData(5, true, false, true, false, false)] + [InlineData(-5, false, true, true, false, false)] + // Specials follow the normal air-date gating when the specials option is on. + [InlineData(5, true, false, true, true, true)] + [InlineData(5, false, false, true, true, false)] + [InlineData(-5, false, true, true, true, true)] + [InlineData(-5, false, false, true, true, false)] + public void ShouldImportEpisode_RespectsAirDateAndOptions(int? dayOffset, bool importUnaired, bool importMissing, bool isSpecial, bool importSpecials, bool expected) + { + DateTime? premiere = dayOffset.HasValue ? _today.AddDays(dayOffset.Value) : null; + + Assert.Equal(expected, TmdbMissingEpisodeProvider.ShouldImportEpisode(premiere, _today, importUnaired, importMissing, isSpecial, importSpecials)); + } + + [Fact] + public void ShouldPrune_AgedOutVirtualTmdbEpisode_ReturnsTrue() + { + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_NotInPruningMode_ReturnsFalse() + { + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_StillUpcoming_ReturnsFalse() + { + var episode = VirtualEpisode(_today.AddDays(1), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_VirtualEpisodeFromAnotherProvider_ReturnsFalse() + { + // No TMDb id -> not created by this provider (e.g. a TheTVDB plugin entry) -> left untouched. + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: false); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_PhysicalEpisode_ReturnsFalse() + { + var episode = new Episode { Path = "/media/show/Season 01/s01e01.mkv", PremiereDate = _today.AddDays(-1) }; + episode.SetProviderId(MetadataProvider.Tmdb, "123"); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_AiredWithinGracePeriod_ReturnsFalse() + { + // Aired two days ago but the grace period keeps it around for the file to be added. + var episode = VirtualEpisode(_today.AddDays(-2), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_AiredBeyondGracePeriod_ReturnsTrue() + { + var episode = VirtualEpisode(_today.AddDays(-10), withTmdbId: true); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_SpecialWithSpecialsDisabled_ReturnsTrue() + { + // Specials are removed entirely when the specials option is off, even when not in pruning mode. + var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 7, importSpecials: false)); + } + + [Fact] + public void ShouldPrune_SpecialWithSpecialsEnabled_FollowsNormalRules() + { + // With specials enabled, an upcoming special is kept like any other upcoming episode. + var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void GetPremiereDate_NullAirDate_ReturnsNull() + { + Assert.Null(TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = null })); + } + + [Fact] + public void GetPremiereDate_AirDate_ReturnsUtc() + { + var airDate = new DateTime(2026, 7, 28); + + var result = TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = airDate }); + + Assert.NotNull(result); + Assert.Equal(DateTimeKind.Utc, result!.Value.Kind); + Assert.Equal(DateTime.SpecifyKind(airDate, DateTimeKind.Local).ToUniversalTime(), result.Value); + } + + [Fact] + public void UpdateVirtualEpisode_PlaceholderTitleReplaced_UpdatesAndReturnsTrue() + { + var episode = new Episode { Name = "Episode 14" }; + var tmdbEpisode = new TvSeasonEpisode { Name = "The Real Title" }; + + Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null)); + Assert.Equal("The Real Title", episode.Name); + } + + [Fact] + public void UpdateVirtualEpisode_NoChanges_ReturnsFalse() + { + var date = _today; + var episode = new Episode { Name = "Same", Overview = "Description", PremiereDate = date }; + var tmdbEpisode = new TvSeasonEpisode { Name = "Same", Overview = "Description" }; + + Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, date)); + } + + [Fact] + public void UpdateVirtualEpisode_EmptyTmdbValues_DoNotOverwrite() + { + var episode = new Episode { Name = "Existing", Overview = "Existing overview" }; + var tmdbEpisode = new TvSeasonEpisode { Name = string.Empty, Overview = null }; + + Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null)); + Assert.Equal("Existing", episode.Name); + Assert.Equal("Existing overview", episode.Overview); + } + + [Fact] + public void UpdateVirtualEpisode_RescheduledAirDate_UpdatesPremiereAndYear() + { + var episode = new Episode { Name = "X", PremiereDate = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) }; + var newAirDate = new DateTime(2026, 8, 15); + var newPremiere = DateTime.SpecifyKind(newAirDate, DateTimeKind.Local).ToUniversalTime(); + var tmdbEpisode = new TvSeasonEpisode { Name = "X", AirDate = newAirDate }; + + Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, newPremiere)); + Assert.Equal(newPremiere, episode.PremiereDate); + Assert.Equal(2026, episode.ProductionYear); + } + + [Fact] + public void BuildSeasonSortNameTemplate_NoNameSortedPhysicalSeason_ReturnsNull() + { + // No physical season carries a forced (name-based) sort name -> virtual seasons keep their + // bare-index sort, so no template is produced. + var seasons = new[] + { + PhysicalSeason(1, forcedSortName: null), + VirtualSeason(3), + }; + + Assert.Null(TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(seasons)); + } + + [Fact] + public void BuildSeasonSortNameTemplate_MirrorsSiblingConventionAndSwapsNumber() + { + var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: "Season 01"), + VirtualSeason(3), + }); + + Assert.NotNull(template); + // Keeps the sibling's text token and zero-padding width, swapping in the target number. + Assert.Equal("Season 03", template!(3)); + Assert.Equal("Season 12", template(12)); + } + + [Fact] + public void BuildSeasonSortNameTemplate_PreservesNonEnglishToken() + { + var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: "Staffel 1"), + VirtualSeason(2), + }); + + Assert.NotNull(template); + Assert.Equal("Staffel 2", template!(2)); + } + + [Fact] + public void BuildSeasonSortNameTemplate_SiblingWithoutDigits_ReturnsNull() + { + var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: "Miniseries"), + VirtualSeason(2), + }); + + Assert.Null(template); + } + + [Fact] + public void BuildSeasonSortNameTemplate_IgnoresVirtualSeasonsAsReference() + { + // A virtual season's own forced sort name must not be used as the convention source. + var virtualWithForced = VirtualSeason(3); + virtualWithForced.ForcedSortName = "Season 03"; + + Assert.Null(TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: null), + virtualWithForced, + })); + } + + private static Season PhysicalSeason(int indexNumber, string? forcedSortName) + { + var season = new Season { IndexNumber = indexNumber, Path = $"/media/show/Season {indexNumber:00}" }; + if (!string.IsNullOrEmpty(forcedSortName)) + { + season.ForcedSortName = forcedSortName; + } + + return season; + } + + private static Season VirtualSeason(int indexNumber) + => new Season { IndexNumber = indexNumber, IsVirtualItem = true }; + + private static Episode VirtualEpisode(DateTime premiereDate, bool withTmdbId, int? seasonNumber = null) + { + var episode = new Episode { PremiereDate = premiereDate, IsVirtualItem = true, ParentIndexNumber = seasonNumber }; + if (withTmdbId) + { + episode.SetProviderId(MetadataProvider.Tmdb, "123"); + } + + return episode; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs new file mode 100644 index 0000000000..70d8e1f833 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs @@ -0,0 +1,186 @@ +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.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable +{ + private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly PeopleRepository _repository; + + public PeopleRepositoryUpdatePeopleTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + var itemTypeLookup = new ItemTypeLookup(); + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + ctx.BaseItems.Add(new BaseItemEntity + { + Id = _itemId, + Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie], + Name = "Movie", + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }); + ctx.SaveChanges(); + } + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + + _repository = new PeopleRepository( + factory.Object, + itemTypeLookup, + new Mock<IItemQueryHelpers>().Object); + } + + public void Dispose() + { + _connection.Dispose(); + } + + [Fact] + public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Writer, "Novel"), + CreatePerson("Person A", PersonKind.Writer, "Screenplay") + ]); + + using var ctx = CreateDbContext(); + Assert.Single(ctx.Peoples); + Assert.Equal( + ["Novel", "Screenplay"], + ctx.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray()); + } + + [Fact] + public void UpdatePeople_CreditsDifferingOnlyInCase_AreDeduped() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("person a", PersonKind.Actor, "hero") + ]); + + using var ctx = CreateDbContext(); + Assert.Single(ctx.Peoples); + var map = Assert.Single(ctx.PeopleBaseItemMap); + Assert.Equal("Hero", map.Role); + } + + [Fact] + public void UpdatePeople_SamePersonAsDifferentTypes_CreatesOnePersonPerType() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("Person A", PersonKind.Director, string.Empty) + ]); + + using var ctx = CreateDbContext(); + Assert.Equal(2, ctx.Peoples.Count()); + Assert.Equal(2, ctx.PeopleBaseItemMap.Count()); + } + + [Fact] + public void UpdatePeople_RepeatedUpdate_ReusesMappingsAndRefreshesOrder() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("Person B", PersonKind.Actor, "Sidekick") + ]); + + Guid[] peopleIdsBefore; + using (var ctx = CreateDbContext()) + { + peopleIdsBefore = ctx.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray(); + } + + // Reversed order, so the list order of both mappings has to be rewritten. + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person B", PersonKind.Actor, "Sidekick"), + CreatePerson("Person A", PersonKind.Actor, "Hero") + ]); + + using var after = CreateDbContext(); + Assert.Equal(peopleIdsBefore, after.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray()); + Assert.Equal( + ["Sidekick", "Hero"], + after.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray()); + } + + [Fact] + public void UpdatePeople_CreditRemoved_DropsOnlyThatMapping() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Writer, "Novel"), + CreatePerson("Person A", PersonKind.Writer, "Screenplay") + ]); + + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Writer, "Novel") + ]); + + using var ctx = CreateDbContext(); + var map = Assert.Single(ctx.PeopleBaseItemMap); + Assert.Equal("Novel", map.Role); + } + + [Fact] + public void UpdatePeople_RoleCaseChanged_KeepsExistingMapping() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "HERO")]); + + using var ctx = CreateDbContext(); + var map = Assert.Single(ctx.PeopleBaseItemMap); + Assert.Equal("Hero", map.Role); + } + + private static PersonInfo CreatePerson(string name, PersonKind type, string role) + { + return new PersonInfo + { + Name = name, + Type = type, + Role = role + }; + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs index 07c537aee1..a28c1d6dfb 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using AutoFixture; using AutoFixture.AutoMoq; using Emby.Naming.Common; @@ -17,6 +18,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using Moq; using Xunit; @@ -38,9 +40,15 @@ public class FindExtrasTests itemRepository.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 }); + + var strings = LoadCoreStrings(); + fixture.Freeze<Mock<ILocalizationManager>>() + .Setup(l => l.GetServerLocalizedString(It.IsAny<string>())) + .Returns<string>(key => strings.TryGetValue(key, out var value) ? value : key); + _libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>().Do(s => s.AddParts( fixture.Create<IEnumerable<IResolverIgnoreRule>>(), - new List<IItemResolver> { new AudioResolver(fixture.Create<NamingOptions>()) }, + [new AudioResolver(fixture.Create<NamingOptions>())], fixture.Create<IEnumerable<IIntroProvider>>(), fixture.Create<IEnumerable<IBaseItemComparer>>(), fixture.Create<IEnumerable<ILibraryPostScanTask>>())) @@ -51,6 +59,16 @@ public class FindExtrasTests BaseItem.MediaSourceManager ??= fixture.Create<IMediaSourceManager>(); } + private static Dictionary<string, string> LoadCoreStrings() + { + using var stream = typeof(Emby.Server.Implementations.Library.LibraryManager).Assembly + .GetManifestResourceStream("Emby.Server.Implementations.Localization.Core.en-US.json") + ?? throw new InvalidOperationException("Core localization resource is missing"); + + return JsonSerializer.Deserialize<Dictionary<string, string>>(stream) + ?? throw new InvalidOperationException("Core localization resource is empty"); + } + [Fact] public void FindExtras_SeparateMovieFolder_FindsCorrectExtras() { @@ -132,60 +150,60 @@ public class FindExtrasTests It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/trailers/some trailer.mkv", Name = "some trailer.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/behind the scenes", It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/behind the scenes/the making of Up.mkv", Name = "the making of Up.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/theme-music", It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/theme-music/theme2.mp3", Name = "theme2.mp3", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/extras", It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/extras/Honest Trailer.mkv", Name = "Honest Trailer.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); var files = paths.Select(p => new FileSystemMetadata { @@ -289,15 +307,15 @@ public class FindExtrasTests It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/trailers/trailer.jpg", Name = "trailer.jpg", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList(); @@ -320,15 +338,15 @@ public class FindExtrasTests It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/trailers/Trailer 1 (2013).mkv", Name = "Trailer 1 (2013).mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); var files = paths.Select(p => new FileSystemMetadata { @@ -372,4 +390,198 @@ public class FindExtrasTests Assert.Equal("/series/Dexter/trailer.mkv", extras[0].Path); Assert.Equal("/series/Dexter/trailers/trailer2.mkv", extras[1].Path); } + + [Fact] + public void FindExtras_SameExtraInSeveralContainers_ReturnsEach() + { + var owner = new Movie { Name = "Skyscraper", Path = "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv" }; + var paths = new List<string> + { + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + // A container is a separate file that plays on its own, so it is a separate extra + Assert.Equal(4, extras.Count); + Assert.Equal("Behind The Scenes", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv"]); + Assert.Equal("Behind The Scenes 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4"]); + Assert.Equal("Trailer", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv"]); + Assert.Equal("Trailer 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4"]); + } + + [Fact] + public void FindExtras_SameExtraInSeveralResolutions_ReturnsEach() + { + var owner = new Movie { Name = "Dragon 2", Path = "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv" }; + var paths = new List<string> + { + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv", + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv", + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal(2, extras.Count); + Assert.Equal("Trailer", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv"]); + Assert.Equal("Trailer 2", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv"]); + } + + [Fact] + public void FindExtras_NumberedExtras_AreKeptApart() + { + 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", + "/movies/Up (2009)/Up (2009)-trailer2.mkv", + "/movies/Up (2009)/Up (2009)-trailer2.mp4", + "/movies/Up (2009)/Up (2009)-trailer3.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList(); + + Assert.Equal(4, extras.Count); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer.mkv", extras[0].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mkv", extras[1].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mp4", extras[2].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer3.mkv", extras[3].Path); + + // The index in the file name is not the number the extra is given, which counts the + // extras of a type as they are found + Assert.Equal("Trailer", extras[0].Name); + Assert.Equal("Trailer 2", extras[1].Name); + Assert.Equal("Trailer 3", extras[2].Name); + Assert.Equal("Trailer 4", extras[3].Name); + } + + [Fact] + public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle() + { + 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", + "/movies/Up (2009)/Recording the audio-behindthescenes.mkv", + "/movies/Up (2009)/Up (2009)-behindthescenes.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal(3, extras.Count); + Assert.Equal("Trailer", extras["/movies/Up (2009)/Up (2009)-trailer.mkv"]); + + // A descriptive file name is a real title and survives, and does not consume a number + Assert.Equal("Recording the audio", extras["/movies/Up (2009)/Recording the audio-behindthescenes.mkv"]); + Assert.Equal("Behind The Scenes", extras["/movies/Up (2009)/Up (2009)-behindthescenes.mkv"]); + } + + [Fact] + public void FindExtras_ExtraInOwnFolder_IsNamedAfterItsFile() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; + var paths = new List<string> + { + "/movies/Up/Up.mkv", + "/movies/Up/trailers" + }; + + _fileSystemMock.Setup(f => f.GetFiles( + "/movies/Up/trailers", + It.IsAny<string[]>(), + false, + false)) + .Returns( + [ + new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Comic-Con Reel.mkv", Name = "Comic-Con Reel.mkv", IsDirectory = false } + ]).Verifiable(); + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + Name = Path.GetFileName(p), + IsDirectory = !Path.HasExtension(p) + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + _fileSystemMock.Verify(); + Assert.Equal(2, extras.Count); + Assert.Equal("Teaser", extras["/movies/Up/trailers/Teaser.mkv"]); + Assert.Equal("Comic-Con Reel", extras["/movies/Up/trailers/Comic-Con Reel.mkv"]); + } + + [Fact] + public void FindExtras_DistinctExtrasInSameFolder_AreKeptApart() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; + var paths = new List<string> + { + "/movies/Up/Up.mkv", + "/movies/Up/trailers" + }; + + _fileSystemMock.Setup(f => f.GetFiles( + "/movies/Up/trailers", + It.IsAny<string[]>(), + false, + false)) + .Returns( + [ + new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Official.mkv", Name = "Official.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Official.mp4", Name = "Official.mp4", IsDirectory = false } + ]).Verifiable(); + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + Name = Path.GetFileName(p), + IsDirectory = !Path.HasExtension(p) + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList(); + + _fileSystemMock.Verify(); + Assert.Equal(3, extras.Count); + Assert.Equal("/movies/Up/trailers/Official.mkv", extras[0].Path); + Assert.Equal("/movies/Up/trailers/Official.mp4", extras[1].Path); + Assert.Equal("/movies/Up/trailers/Teaser.mkv", extras[2].Path); + } } |
