diff options
| author | pokreman06 <112423673+pokreman06@users.noreply.github.com> | 2025-10-02 11:07:05 -0600 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-10-02 11:07:05 -0600 |
| commit | 0b4854c5eff7c862d05f43048e08dd3a1a25efaa (patch) | |
| tree | a4c417af05deef7878ab9342c85c506ad22e1ced | |
| parent | d6a1c8413c6a213f6e579246c1b85aad9b028b3a (diff) | |
| parent | 0f42aa892e0a7fe2ac4e680e7647515af0909e5e (diff) | |
Merge branch 'jellyfin:master' into master
96 files changed, 5375 insertions, 790 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 5efa53e31f..b4d77bc4c6 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "9.0.8", + "version": "9.0.9", "commands": [ "dotnet-ef" ] diff --git a/.editorconfig b/.editorconfig index ab5d3d9dd1..313b02563d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -294,6 +294,9 @@ dotnet_diagnostic.CA1854.severity = error # error on CA1860: Avoid using 'Enumerable.Any()' extension method dotnet_diagnostic.CA1860.severity = error +# error on CA1861: Avoid constant arrays as arguments +dotnet_diagnostic.CA1861.severity = error + # error on CA1862: Use the 'StringComparison' method overloads to perform case-insensitive string comparisons dotnet_diagnostic.CA1862.severity = error diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 4f0c1007f1..89d59e4c4a 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '9.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3.30.1 + uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3.30.1 + uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3.30.1 + uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 82f9dc3c8e..f2cf967e93 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@c4c5175a441c6603ec614f5084386dabe0e2295b # v5.4.12 + uses: danielpalme/ReportGenerator-GitHub-Action@1978db745da4a573ca4baa2d0f67175df51a148c # v5.4.16 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 66ef2a07ed..0a4114478f 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -31,6 +31,7 @@ - [DaveChild](https://github.com/DaveChild) - [DavidFair](https://github.com/DavidFair) - [Delgan](https://github.com/Delgan) + - [Derpipose](https://github.com/Derpipose) - [dcrdev](https://github.com/dcrdev) - [dhartung](https://github.com/dhartung) - [dinki](https://github.com/dinki) @@ -140,6 +141,7 @@ - [ThibaultNocchi](https://github.com/ThibaultNocchi) - [thornbill](https://github.com/thornbill) - [ThreeFive-O](https://github.com/ThreeFive-O) + - [tjwalkr3](https://github.com/tjwalkr3) - [TrisMcC](https://github.com/TrisMcC) - [trumblejoe](https://github.com/trumblejoe) - [TtheCreator](https://github.com/TtheCreator) diff --git a/Directory.Build.props b/Directory.Build.props index 31ae8bfbe4..8400f4c5e7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -19,4 +19,9 @@ <AdditionalFiles Include="$(MSBuildThisFileDirectory)/stylecop.json" /> </ItemGroup> + <!-- Custom Analyzers --> + <ItemGroup Condition=" '$(MSBuildProjectName)' != 'Jellyfin.CodeAnalysis' AND '$(Configuration)' == 'Debug' "> + <ProjectReference Include="$(MSBuildThisFileDirectory)src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj" OutputItemType="Analyzer" /> + </ItemGroup> + </Project> diff --git a/Directory.Packages.props b/Directory.Packages.props index 7547919b88..35f8ed4cdc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,40 +26,43 @@ <PackageVersion Include="libse" Version="4.0.12" /> <PackageVersion Include="LrcParser" Version="2025.623.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" /> - <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.8" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.8" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.9" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.9" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" /> - <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.8" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.8" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.8" /> + <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" /> + <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" /> + <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" /> + <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.9" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.9" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.9" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.9" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageVersion Include="MimeTypes" Version="2.5.2" /> <PackageVersion Include="Morestachio" Version="5.0.1.631" /> <PackageVersion Include="Moq" Version="4.18.4" /> <PackageVersion Include="NEbml" Version="1.1.0.5" /> - <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /> + <PackageVersion Include="Newtonsoft.Json" Version="13.0.4" /> <PackageVersion Include="PlaylistsNET" Version="1.4.1" /> <PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" /> <PackageVersion Include="prometheus-net" Version="8.2.1" /> - <PackageVersion Include="Polly" Version="8.6.3" /> + <PackageVersion Include="Polly" Version="8.6.4" /> <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" /> <PackageVersion Include="Serilog.Expressions" Version="5.0.0" /> @@ -70,27 +73,27 @@ <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> <PackageVersion Include="SharpFuzz" Version="2.2.0" /> - <!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 --> + <!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 --> <PackageVersion Include="SkiaSharp" Version="3.116.1" /> <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.116.1" /> <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" /> <PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" /> <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" /> - <PackageVersion Include="Svg.Skia" Version="3.0.6" /> + <PackageVersion Include="Svg.Skia" Version="3.2.1" /> <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" /> <PackageVersion Include="System.Globalization" Version="4.3.0" /> <PackageVersion Include="System.Linq.Async" Version="6.0.3" /> - <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.8" /> - <PackageVersion Include="System.Text.Json" Version="9.0.8" /> - <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.8" /> + <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.9" /> + <PackageVersion Include="System.Text.Json" Version="9.0.9" /> + <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.9" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" /> - <PackageVersion Include="z440.atl.core" Version="7.3.0" /> - <PackageVersion Include="TMDbLib" Version="2.2.0" /> + <PackageVersion Include="z440.atl.core" Version="7.5.0" /> + <PackageVersion Include="TMDbLib" Version="2.3.0" /> <PackageVersion Include="UTF.Unknown" Version="2.6.0" /> <PackageVersion Include="Xunit.Priority" Version="1.1.6" /> <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" /> <PackageVersion Include="xunit" Version="2.9.3" /> </ItemGroup> -</Project> +</Project>
\ No newline at end of file diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 1c518f0cca..f61ca7e129 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -21,8 +21,8 @@ namespace Emby.Naming.Common /// </summary> public NamingOptions() { - VideoFileExtensions = new[] - { + VideoFileExtensions = + [ ".001", ".3g2", ".3gp", @@ -77,10 +77,10 @@ namespace Emby.Naming.Common ".wmv", ".wtv", ".xvid" - }; + ]; - VideoFlagDelimiters = new[] - { + VideoFlagDelimiters = + [ '(', ')', '-', @@ -88,15 +88,15 @@ namespace Emby.Naming.Common '_', '[', ']' - }; + ]; - StubFileExtensions = new[] - { + StubFileExtensions = + [ ".disc" - }; + ]; - StubTypes = new[] - { + StubTypes = + [ new StubTypeRule( stubType: "dvd", token: "dvd"), @@ -136,32 +136,32 @@ namespace Emby.Naming.Common new StubTypeRule( stubType: "tv", token: "DSR") - }; + ]; - VideoFileStackingRules = new[] - { + VideoFileStackingRules = + [ new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[0-9]+)[\)\]]?(?:\.[^.]+)?$", true), new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[a-d])[\)\]]?(?:\.[^.]+)?$", false) - }; + ]; - CleanDateTimes = new[] - { + CleanDateTimes = + [ @"(.+[^_\,\.\(\)\[\]\-])[_\.\(\)\[\]\-](19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*", @"(.+[^_\,\.\(\)\[\]\-])[ _\.\(\)\[\]\-]+(19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*" - }; + ]; - CleanStrings = new[] - { + CleanStrings = + [ @"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)", @"^(?<cleaned>.+?)(\[.*\])", @"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)", @"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)", @"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$", @"^\s*(?<cleaned>.+?)(([-._ ](trailer|sample))|-(scene|clip|behindthescenes|deleted|deletedscene|featurette|short|interview|other|extra))$" - }; + ]; - SubtitleFileExtensions = new[] - { + SubtitleFileExtensions = + [ ".ass", ".mks", ".sami", @@ -171,17 +171,17 @@ namespace Emby.Naming.Common ".sub", ".sup", ".vtt", - }; + ]; - LyricFileExtensions = new[] - { + LyricFileExtensions = + [ ".lrc", ".elrc", ".txt" - }; + ]; - AlbumStackingPrefixes = new[] - { + AlbumStackingPrefixes = + [ "cd", "digital media", "disc", @@ -190,10 +190,10 @@ namespace Emby.Naming.Common "volume", "part", "act" - }; + ]; - ArtistSubfolders = new[] - { + ArtistSubfolders = + [ "albums", "broadcasts", "bootlegs", @@ -208,10 +208,10 @@ namespace Emby.Naming.Common "soundtracks", "spokenwords", "streets" - }; + ]; - AudioFileExtensions = new[] - { + AudioFileExtensions = + [ ".669", ".3gp", ".aa", @@ -241,6 +241,7 @@ namespace Emby.Naming.Common ".dts", ".dvf", ".eac3", + ".ec3", ".far", ".flac", ".gdm", @@ -291,33 +292,33 @@ namespace Emby.Naming.Common ".xm", ".xsp", ".ymf" - }; + ]; - MediaFlagDelimiters = new[] - { + MediaFlagDelimiters = + [ '.' - }; + ]; - MediaForcedFlags = new[] - { + MediaForcedFlags = + [ "foreign", "forced" - }; + ]; - MediaDefaultFlags = new[] - { + MediaDefaultFlags = + [ "default" - }; + ]; - MediaHearingImpairedFlags = new[] - { + MediaHearingImpairedFlags = + [ "cc", "hi", "sdh" - }; + ]; - EpisodeExpressions = new[] - { + EpisodeExpressions = + [ // *** Begin Kodi Standard Naming // <!-- foo.s01.e01, foo.s01_e01, S01E02 foo, S01 - E02 --> new EpisodeExpression(@".*(\\|\/)(?<seriesname>((?[][ ._-]*[Ee]([0-9]+))[^\\\/])*)?[Ss](?<seasonnumber>[0-9]+)[][ ._-]*[Ee](?<epnumber>[0-9]+)([^\\/]*)$") @@ -330,23 +331,23 @@ namespace Emby.Naming.Common new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"), new EpisodeExpression("(?<year>[0-9]{4})[._ -](?<month>[0-9]{2})[._ -](?<day>[0-9]{2})", true) { - DateTimeFormats = new[] - { + DateTimeFormats = + [ "yyyy.MM.dd", "yyyy-MM-dd", "yyyy_MM_dd", "yyyy MM dd" - } + ] }, new EpisodeExpression("(?<day>[0-9]{2})[._ -](?<month>[0-9]{2})[._ -](?<year>[0-9]{4})", true) { - DateTimeFormats = new[] - { + DateTimeFormats = + [ "dd.MM.yyyy", "dd-MM-yyyy", "dd_MM_yyyy", "dd MM yyyy" - } + ] }, // This isn't a Kodi naming rule, but the expression below causes false episode numbers for @@ -478,10 +479,10 @@ namespace Emby.Naming.Common { IsNamed = true }, - }; + ]; - VideoExtraRules = new[] - { + VideoExtraRules = + [ new ExtraRule( ExtraType.Trailer, ExtraRuleType.DirectoryName, @@ -691,14 +692,14 @@ namespace Emby.Naming.Common ExtraRuleType.Suffix, "-other", MediaType.Video) - }; + ]; AllExtrasTypesFolderNames = VideoExtraRules .Where(i => i.RuleType == ExtraRuleType.DirectoryName) .ToDictionary(i => i.Token, i => i.ExtraType, StringComparer.OrdinalIgnoreCase); - Format3DRules = new[] - { + Format3DRules = + [ // Kodi rules: new Format3DRule( precedingToken: "3d", @@ -725,10 +726,10 @@ namespace Emby.Naming.Common new Format3DRule("tab"), new Format3DRule("sbs3d"), new Format3DRule("mvc") - }; + ]; - AudioBookPartsExpressions = new[] - { + AudioBookPartsExpressions = + [ // Detect specified chapters, like CH 01 @"ch(?:apter)?[\s_-]?(?<chapter>[0-9]+)", // Detect specified parts, like Part 02 @@ -741,14 +742,14 @@ namespace Emby.Naming.Common "(?<chapter>[0-9]+)_(?<part>[0-9]+)", // Some audiobooks are ripped from cd's, and will be named by disk number. @"dis(?:c|k)[\s_-]?(?<chapter>[0-9]+)" - }; + ]; - AudioBookNamesExpressions = new[] - { + AudioBookNamesExpressions = + [ // Detect year usually in brackets after name Batman (2020) @"^(?<name>.+?)\s*\(\s*(?<year>[0-9]{4})\s*\)\s*$", @"^\s*(?<name>[^ ].*?)\s*$" - }; + ]; MultipleEpisodeExpressions = new[] { @@ -888,12 +889,12 @@ namespace Emby.Naming.Common /// <summary> /// Gets list of clean datetime regular expressions. /// </summary> - public Regex[] CleanDateTimeRegexes { get; private set; } = Array.Empty<Regex>(); + public Regex[] CleanDateTimeRegexes { get; private set; } = []; /// <summary> /// Gets list of clean string regular expressions. /// </summary> - public Regex[] CleanStringRegexes { get; private set; } = Array.Empty<Regex>(); + public Regex[] CleanStringRegexes { get; private set; } = []; /// <summary> /// Compiles raw regex strings into regexes. diff --git a/Emby.Naming/Video/StackResolver.cs b/Emby.Naming/Video/StackResolver.cs index 8119a02674..6a07561a06 100644 --- a/Emby.Naming/Video/StackResolver.cs +++ b/Emby.Naming/Video/StackResolver.cs @@ -132,7 +132,7 @@ namespace Emby.Naming.Video } } - private class StackMetadata + private sealed class StackMetadata { public StackMetadata(bool isDirectory, bool isNumerical, string partType) { diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index e74755ec32..c69bcfef78 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -108,7 +108,7 @@ namespace Emby.Server.Implementations.AppBase private void CheckOrCreateMarker(string path, string markerName, bool recursive = false) { var otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => Path.GetFileName(e) != markerName); - if (otherMarkers != null) + if (otherMarkers is not null) { throw new InvalidOperationException($"Exepected to find only {markerName} but found marker for {otherMarkers}."); } diff --git a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs index 31ae82d6a3..676bb7f816 100644 --- a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs +++ b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs @@ -50,6 +50,8 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask _logger.LogDebug("Cleaning {Number} items with dead parents", numItems); + IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2)); + foreach (var itemId in itemIds) { cancellationToken.ThrowIfCancellationRequested(); @@ -95,9 +97,10 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask numComplete++; double percent = numComplete; percent /= numItems; - progress.Report(percent * 100); + subProgress.Report(percent * 100); } + subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50)); var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { @@ -105,7 +108,9 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask await using (transaction.ConfigureAwait(false)) { await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + subProgress.Report(50); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + subProgress.Report(100); } } diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 0db1606ea5..c5dc3b054c 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1051,30 +1051,15 @@ namespace Emby.Server.Implementations.Dto // Include artists that are not in the database yet, e.g., just added via metadata editor // var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList(); - dto.ArtistItems = hasArtist.Artists - // .Except(foundArtists, new DistinctNameComparer()) + dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]) + .Where(e => e.Value.Length > 0) .Select(i => { - // This should not be necessary but we're seeing some cases of it - if (string.IsNullOrEmpty(i)) - { - return null; - } - - var artist = _libraryManager.GetArtist(i, new DtoOptions(false) - { - EnableImages = false - }); - if (artist is not null) + return new NameGuidPair { - return new NameGuidPair - { - Name = artist.Name, - Id = artist.Id - }; - } - - return null; + Name = i.Key, + Id = i.Value.First().Id + }; }).Where(i => i is not null).ToArray(); } diff --git a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs index f9538fbad6..ca0744a17d 100644 --- a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs @@ -37,6 +37,11 @@ namespace Emby.Server.Implementations.Library return false; } + if (IgnorePatterns.ShouldIgnore(fileInfo.FullName)) + { + return true; + } + // Don't ignore top level folders if (fileInfo.IsDirectory && (parent is AggregateFolder || (parent?.IsTopParent ?? false))) @@ -44,11 +49,6 @@ namespace Emby.Server.Implementations.Library return false; } - if (IgnorePatterns.ShouldIgnore(fileInfo.FullName)) - { - return true; - } - if (parent is null) { return false; diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs index 401ca73b80..bafe3ad436 100644 --- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs @@ -50,6 +50,13 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule return false; } + // Fast path in case the ignore files isn't a symlink and is empty + if ((dirIgnoreFile.Attributes & FileAttributes.ReparsePoint) == 0 + && dirIgnoreFile.Length == 0) + { + return true; + } + // ignore the directory only if the .ignore file is empty // evaluate individual files otherwise return string.IsNullOrWhiteSpace(GetFileContent(dirIgnoreFile)); diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs index 25ddade829..fe3a1ce611 100644 --- a/Emby.Server.Implementations/Library/IgnorePatterns.cs +++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs @@ -48,6 +48,8 @@ namespace Emby.Server.Implementations.Library "**/.wd_tv", "**/lost+found/**", "**/lost+found", + "**/subs/**", + "**/subs", // Trickplay files "**/*.trickplay", diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 58a971f62a..ef497726e2 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -327,6 +327,45 @@ namespace Emby.Server.Implementations.Library DeleteItem(item, options, parent, notifyParentItem); } + public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items) + { + var pathMaps = items.Select(e => (Item: e, InternalPath: GetInternalMetadataPaths(e), DeletePaths: e.GetDeletePaths())).ToArray(); + + foreach (var (item, internalPaths, pathsToDelete) in pathMaps) + { + foreach (var metadataPath in internalPaths) + { + if (!Directory.Exists(metadataPath)) + { + continue; + } + + _logger.LogDebug( + "Deleting metadata path, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}", + item.GetType().Name, + item.Name ?? "Unknown name", + metadataPath, + item.Id); + + try + { + Directory.Delete(metadataPath, true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting {MetadataPath}", metadataPath); + } + } + + foreach (var fileSystemInfo in pathsToDelete) + { + DeleteItemPath(item, false, fileSystemInfo); + } + } + + _itemRepository.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]); + } + public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem) { ArgumentNullException.ThrowIfNull(item); @@ -403,59 +442,7 @@ namespace Emby.Server.Implementations.Library foreach (var fileSystemInfo in item.GetDeletePaths()) { - if (Directory.Exists(fileSystemInfo.FullName) || File.Exists(fileSystemInfo.FullName)) - { - try - { - _logger.LogInformation( - "Deleting item path, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}", - item.GetType().Name, - item.Name ?? "Unknown name", - fileSystemInfo.FullName, - item.Id); - - if (fileSystemInfo.IsDirectory) - { - Directory.Delete(fileSystemInfo.FullName, true); - } - else - { - File.Delete(fileSystemInfo.FullName); - } - } - catch (DirectoryNotFoundException) - { - _logger.LogInformation( - "Directory not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}", - item.GetType().Name, - item.Name ?? "Unknown name", - fileSystemInfo.FullName, - item.Id); - } - catch (FileNotFoundException) - { - _logger.LogInformation( - "File not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}", - item.GetType().Name, - item.Name ?? "Unknown name", - fileSystemInfo.FullName, - item.Id); - } - catch (IOException) - { - if (isRequiredForDelete) - { - throw; - } - } - catch (UnauthorizedAccessException) - { - if (isRequiredForDelete) - { - throw; - } - } - } + DeleteItemPath(item, isRequiredForDelete, fileSystemInfo); isRequiredForDelete = false; } @@ -463,17 +450,73 @@ namespace Emby.Server.Implementations.Library item.SetParent(null); - _itemRepository.DeleteItem(item.Id); + _itemRepository.DeleteItem([item.Id, .. children.Select(f => f.Id)]); _cache.TryRemove(item.Id, out _); foreach (var child in children) { - _itemRepository.DeleteItem(child.Id); _cache.TryRemove(child.Id, out _); } ReportItemRemoved(item, parent); } + private void DeleteItemPath(BaseItem item, bool isRequiredForDelete, FileSystemMetadata fileSystemInfo) + { + if (Directory.Exists(fileSystemInfo.FullName) || File.Exists(fileSystemInfo.FullName)) + { + try + { + _logger.LogInformation( + "Deleting item path, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}", + item.GetType().Name, + item.Name ?? "Unknown name", + fileSystemInfo.FullName, + item.Id); + + if (fileSystemInfo.IsDirectory) + { + Directory.Delete(fileSystemInfo.FullName, true); + } + else + { + File.Delete(fileSystemInfo.FullName); + } + } + catch (DirectoryNotFoundException) + { + _logger.LogInformation( + "Directory not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}", + item.GetType().Name, + item.Name ?? "Unknown name", + fileSystemInfo.FullName, + item.Id); + } + catch (FileNotFoundException) + { + _logger.LogInformation( + "File not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}", + item.GetType().Name, + item.Name ?? "Unknown name", + fileSystemInfo.FullName, + item.Id); + } + catch (IOException) + { + if (isRequiredForDelete) + { + throw; + } + } + catch (UnauthorizedAccessException) + { + if (isRequiredForDelete) + { + throw; + } + } + } + } + private bool IsInternalItem(BaseItem item) { if (!item.IsFileProtocol) @@ -485,7 +528,7 @@ namespace Emby.Server.Implementations.Library { Genre => _configurationManager.ApplicationPaths.GenrePath, MusicArtist => _configurationManager.ApplicationPaths.ArtistsPath, - MusicGenre => _configurationManager.ApplicationPaths.GenrePath, + MusicGenre => _configurationManager.ApplicationPaths.MusicGenrePath, Person => _configurationManager.ApplicationPaths.PeoplePath, Studio => _configurationManager.ApplicationPaths.StudioPath, Year => _configurationManager.ApplicationPaths.YearPath, @@ -826,6 +869,7 @@ namespace Emby.Server.Implementations.Library if (!folder.ParentId.Equals(rootFolder.Id)) { + rootFolder.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, CancellationToken.None).GetAwaiter().GetResult(); folder.ParentId = rootFolder.Id; folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, CancellationToken.None).GetAwaiter().GetResult(); } @@ -989,6 +1033,11 @@ namespace Emby.Server.Implementations.Library return GetArtist(name, new DtoOptions(true)); } + public IReadOnlyDictionary<string, MusicArtist[]> GetArtists(IReadOnlyList<string> names) + { + return _itemRepository.FindArtists(names); + } + public MusicArtist GetArtist(string name, DtoOptions options) { return CreateItemByName<MusicArtist>(MusicArtist.GetPath, name, options); @@ -1090,6 +1139,7 @@ namespace Emby.Server.Implementations.Library public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false) { + RootFolder.Children = null; await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); // Start by just validating the children of the root, but go no further @@ -1100,9 +1150,12 @@ namespace Emby.Server.Implementations.Library allowRemoveRoot: removeRoot, cancellationToken: cancellationToken).ConfigureAwait(false); - await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false); + var rootFolder = GetUserRootFolder(); + rootFolder.Children = null; + + await rootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); - await GetUserRootFolder().ValidateChildren( + await rootFolder.ValidateChildren( new Progress<double>(), new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: false, @@ -1110,18 +1163,24 @@ namespace Emby.Server.Implementations.Library cancellationToken: cancellationToken).ConfigureAwait(false); // Quickly scan CollectionFolders for changes - foreach (var child in GetUserRootFolder().Children.OfType<Folder>()) + var toDelete = new List<Guid>(); + foreach (var child in rootFolder.Children!.OfType<Folder>()) { // If the user has somehow deleted the collection directory, remove the metadata from the database. if (child is CollectionFolder collectionFolder && !Directory.Exists(collectionFolder.Path)) { - _itemRepository.DeleteItem(collectionFolder.Id); + toDelete.Add(collectionFolder.Id); } else { await child.RefreshMetadata(cancellationToken).ConfigureAwait(false); } } + + if (toDelete.Count > 0) + { + _itemRepository.DeleteItem(toDelete.ToArray()); + } } private async Task PerformLibraryValidation(IProgress<double> progress, CancellationToken cancellationToken) @@ -2027,6 +2086,12 @@ namespace Emby.Server.Implementations.Library } } + if (!File.Exists(image.Path)) + { + _logger.LogWarning("Image not found at {ImagePath}", image.Path); + continue; + } + ImageDimensions size; try { diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 1e3b8ea760..750346169f 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -657,7 +657,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogDebug(ex, "_jsonSerializer.DeserializeFromFile threw an exception."); + _logger.LogDebug(ex, "Error parsing cached media info."); } finally { diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index 28cf695007..e0c8ae371b 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -45,11 +45,14 @@ namespace Emby.Server.Implementations.Library public IReadOnlyList<BaseItem> GetInstantMixFromFolder(Folder item, User? user, DtoOptions dtoOptions) { var genres = item - .GetRecursiveChildren(user, new InternalItemsQuery(user) - { - IncludeItemTypes = [BaseItemKind.Audio], - DtoOptions = dtoOptions - }) + .GetRecursiveChildren( + user, + new InternalItemsQuery(user) + { + IncludeItemTypes = [BaseItemKind.Audio], + DtoOptions = dtoOptions + }, + out _) .Cast<Audio>() .SelectMany(i => i.Genres) .Concat(item.Genres) diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index b2ceee97d8..333c8c34bf 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -405,6 +405,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (child.IsDirectory) { + if (NamingOptions.AllExtrasTypesFolderNames.ContainsKey(filename)) + { + continue; + } + if (IsDvdDirectory(child.FullName, filename, directoryService)) { var movie = new T diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index be1d96bf0b..72c8d7a9d2 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -80,6 +80,7 @@ namespace Emby.Server.Implementations.Library var userId = user.InternalId; var cacheKey = GetCacheKey(userId, item.Id); _cache.AddOrUpdate(cacheKey, userData); + item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); // rehydrate the cached userdata UserDataSaved?.Invoke(this, new UserDataSaveEventArgs { @@ -159,7 +160,7 @@ namespace Emby.Server.Implementations.Library }; } - private UserItemData Map(UserData dto) + private static UserItemData Map(UserData dto) { return new UserItemData() { @@ -237,7 +238,10 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public UserItemData? GetUserData(User user, BaseItem item) { - return GetUserData(user, item.Id, item.GetUserDataKeys()); + return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData() + { + Key = item.GetUserDataKeys()[0], + }; } /// <inheritdoc /> @@ -304,7 +308,7 @@ namespace Emby.Server.Implementations.Library // ignore progress during the beginning positionTicks = 0; } - else if (pctIn > _config.Configuration.MaxResumePct || positionTicks >= runtimeTicks) + else if (pctIn > _config.Configuration.MaxResumePct || positionTicks >= (runtimeTicks - TimeSpan.TicksPerSecond)) { // mark as completed close to the end positionTicks = 0; diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index b7fd24fa5c..f9a6f0d19e 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -1,5 +1,5 @@ using System; -using System.Globalization; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -55,6 +55,8 @@ public class PeopleValidator var numPeople = people.Count; + IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2)); + _logger.LogDebug("Will refresh {Amount} people", numPeople); foreach (var person in people) @@ -92,7 +94,7 @@ public class PeopleValidator double percent = numComplete; percent /= numPeople; - progress.Report(100 * percent); + subProgress.Report(100 * percent); } var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery @@ -102,17 +104,13 @@ public class PeopleValidator IsLocked = false }); - foreach (var item in deadEntities) - { - _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); + subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50)); - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = false - }, - false); + var i = 0; + foreach (var item in deadEntities.Chunk(500)) + { + _libraryManager.DeleteItemsUnsafeFast(item); + subProgress.Report(100f / deadEntities.Count * (i++ * 100)); } progress.Report(100); diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index dec491d08b..29847048cb 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -1,16 +1,16 @@ { "Sync": "Сінхранізаваць", - "Playlists": "Спісы прайгравання", - "Latest": "Апошні", + "Playlists": "Плэй-лісты", + "Latest": "Апошняе", "LabelIpAddressValue": "IP-адрас: {0}", - "ItemAddedWithName": "{0} быў дададзены ў бібліятэку", + "ItemAddedWithName": "{0} даданы ў бібліятэку", "MessageApplicationUpdated": "Сервер Jellyfin абноўлены", - "NotificationOptionApplicationUpdateInstalled": "Абнаўленне прыкладання ўсталявана", + "NotificationOptionApplicationUpdateInstalled": "Абнаўленне праграмы ўсталявана", "PluginInstalledWithName": "{0} быў усталяваны", "UserCreatedWithName": "Карыстальнік {0} быў створаны", "Albums": "Альбомы", - "Application": "Прыкладанне", - "AuthenticationSucceededWithUserName": "{0} паспяхова аўтэнтыфікаваны", + "Application": "Праграма", + "AuthenticationSucceededWithUserName": "{0} паспяхова аўтарызаваны", "Channels": "Каналы", "ChapterNameValue": "Раздзел {0}", "Collections": "Калекцыі", @@ -29,18 +29,18 @@ "HeaderAlbumArtists": "Выканаўцы альбома", "LabelRunningTimeValue": "Працягласць: {0}", "HomeVideos": "Хатнія відэа", - "ItemRemovedWithName": "{0} быў выдалены з бібліятэкі", - "MessageApplicationUpdatedTo": "Сервер Jellyfin абноўлены да {0}", + "ItemRemovedWithName": "{0} выдалены з бібліятэкі", + "MessageApplicationUpdatedTo": "Сервер Jellyfin абноўлены да версіі {0}", "Movies": "Фільмы", "Music": "Музыка", "MusicVideos": "Музычныя кліпы", - "NameInstallFailed": "Устаноўка {0} не атрымалася", + "NameInstallFailed": "Усталяванне {0} не атрымалася", "NameSeasonNumber": "Сезон {0}", - "NotificationOptionApplicationUpdateAvailable": "Даступна абнаўленне прыкладання", + "NotificationOptionApplicationUpdateAvailable": "Даступна абнаўленне праграмы", "NotificationOptionPluginInstalled": "Плагін усталяваны", - "NotificationOptionPluginUpdateInstalled": "Абнаўленне плагіна усталявана", + "NotificationOptionPluginUpdateInstalled": "Абнаўленне плагіна ўсталявана", "NotificationOptionServerRestartRequired": "Патрабуецца перазапуск сервера", - "Photos": "Фатаграфіі", + "Photos": "Фотаздымкі", "Plugin": "Плагін", "PluginUninstalledWithName": "{0} быў выдалены", "PluginUpdatedWithName": "{0} быў абноўлены", @@ -54,16 +54,16 @@ "Artists": "Выканаўцы", "UserOfflineFromDevice": "{0} адлучыўся ад {1}", "UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}", - "TaskCleanActivityLogDescription": "Выдаляе старэйшыя за зададзены ўзрост запісы ў журнале актыўнасці.", + "TaskCleanActivityLogDescription": "Выдаляе запісы старэйшыя за зададзены ўзрост ў журнале актыўнасці.", "TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.", "TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.", - "TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія настроены на аўтаматычнае абнаўленне.", + "TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія сканфігураваныя на аўтаматычнае абнаўленне.", "TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.", - "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субтытры на аснове канфігурацыі метададзеных.", - "TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых змяненняў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць прадукцыйнасць.", + "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метададзеных.", + "TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых зменаў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць выдайнасць.", "TaskKeyframeExtractor": "Экстрактар ключавых кадраў", - "TasksApplicationCategory": "Прыкладанне", - "AppDeviceValues": "Прыкладанне: {0}, Прылада: {1}", + "TasksApplicationCategory": "Праграма", + "AppDeviceValues": "Праграма: {0}, Прылада: {1}", "Books": "Кнігі", "CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}", "DeviceOfflineWithName": "{0} адлучыўся", @@ -74,7 +74,7 @@ "HeaderFavoriteArtists": "Абраныя выканаўцы", "HearingImpaired": "Са слабым слыхам", "Inherit": "Атрымаць у спадчыну", - "MessageNamedServerConfigurationUpdatedWithValue": "Канфігурацыя сервера {0} абноўлена", + "MessageNamedServerConfigurationUpdatedWithValue": "Канфігурацыя сервера (секцыя {0}) абноўлена", "MessageServerConfigurationUpdated": "Канфігурацыя сервера абноўлена", "MixedContent": "Змешаны змест", "NameSeasonUnknown": "Невядомы сезон", @@ -92,48 +92,48 @@ "NotificationOptionVideoPlaybackStopped": "Прайграванне відэа спынена", "ScheduledTaskFailedWithName": "{0} не атрымалася", "ScheduledTaskStartedWithName": "{0} пачалося", - "ServerNameNeedsToBeRestarted": "{0} трэба перазапусціць", + "ServerNameNeedsToBeRestarted": "{0} патрабуе перазапуску", "Shows": "Шоу", "StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.", "SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}", - "TvShows": "ТБ-шоу", + "TvShows": "Тэлепраграма", "Undefined": "Нявызначана", "UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны", "UserOnlineFromDevice": "{0} падключаны з {1}", "UserPasswordChangedWithName": "Пароль быў зменены для карыстальніка {0}", - "UserStartedPlayingItemWithValues": "{0} грае {1} на {2}", + "UserStartedPlayingItemWithValues": "{0} прайграваецца {1} на {2}", "UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}", "ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку", "ValueSpecialEpisodeName": "Спецэпізод - {0}", "VersionNumber": "Версія {0}", "TasksMaintenanceCategory": "Абслугоўванне", - "TasksLibraryCategory": "Медыятэка", + "TasksLibraryCategory": "Бібліятэка", "TasksChannelsCategory": "Інтэрнэт-каналы", "TaskCleanActivityLog": "Ачысціць журнал актыўнасці", "TaskCleanCache": "Ачысціць кэш", "TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.", - "TaskRefreshChapterImages": "Выняць выявы раздзелаў", - "TaskRefreshLibrary": "Сканіраваць медыятэку", - "TaskRefreshLibraryDescription": "Сканіруе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.", - "TaskCleanLogs": "Ачысціць часопіс", - "TaskRefreshPeople": "Абнавіць людзей", + "TaskRefreshChapterImages": "Вынуць выявы раздзелаў", + "TaskRefreshLibrary": "Сканаваць бібліятэку", + "TaskRefreshLibraryDescription": "Скануе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.", + "TaskCleanLogs": "Ачысціць журнал", + "TaskRefreshPeople": "Абнавіць выканаўцаў", "TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.", "TaskUpdatePlugins": "Абнавіць плагіны", "TaskCleanTranscode": "Ачысціць каталог перакадзіравання", "TaskCleanTranscodeDescription": "Выдаляе перакадзіраваныя файлы, старэйшыя за адзін дзень.", "TaskRefreshChannels": "Абнавіць каналы", - "TaskDownloadMissingSubtitles": "Спампаваць адсутныя субтытры", - "TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных спісаў прайгравання HLS. Гэта задача можа працаваць у працягу доўгага часу.", - "TaskRefreshTrickplayImages": "Стварыце выявы Trickplay", - "TaskRefreshTrickplayImagesDescription": "Стварае прагляд відэаролікаў для Trickplay у падключаных бібліятэках.", - "TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і спісы прайгравання", - "TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і спісаў прайгравання, якія больш не існуюць.", - "TaskAudioNormalizationDescription": "Сканіруе файлы на прадмет нармалізацыі гуку.", + "TaskDownloadMissingSubtitles": "Спампаваць адсутныя субцітры", + "TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных плэй-лістоў HLS. Гэта задача можа працягнуцца шмат часу.", + "TaskRefreshTrickplayImages": "Стварыць выявы Trickplay", + "TaskRefreshTrickplayImagesDescription": "Стварае перадпрагляды відэаролікаў для Trickplay у падключаных бібліятэках.", + "TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і плэй-лісты", + "TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і плэй-лістоў, якія больш не існуюць.", + "TaskAudioNormalizationDescription": "Скануе файлы на прадмет нармалізацыі гуку.", "TaskAudioNormalization": "Нармалізацыя гуку", "TaskExtractMediaSegmentsDescription": "Выдае або атрымлівае медыясегменты з убудоў з падтрымкай MediaSegment.", "TaskMoveTrickplayImagesDescription": "Перамяшчае існуючыя файлы trickplay у адпаведнасці з наладамі бібліятэкі.", - "TaskDownloadMissingLyrics": "Спампаваць зніклыя тэксты песень", - "TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песень", + "TaskDownloadMissingLyrics": "Спампаваць адсутныя тэксты песняў", + "TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песняў", "TaskExtractMediaSegments": "Сканіраванне медыя-сегмента", "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay", "CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка", diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index 20f38de62f..52a26c1af2 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -137,5 +137,6 @@ "TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.", "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" + "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." } diff --git a/Emby.Server.Implementations/Localization/Core/es_DO.json b/Emby.Server.Implementations/Localization/Core/es_DO.json index 8cdd06b7c4..f98a5e5b2c 100644 --- a/Emby.Server.Implementations/Localization/Core/es_DO.json +++ b/Emby.Server.Implementations/Localization/Core/es_DO.json @@ -125,5 +125,11 @@ "Undefined": "Sin definir", "TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.", "TaskCleanCacheDescription": "Elimina archivos caché que ya no son necesarios para el sistema.", - "TaskCleanLogsDescription": "Elimina archivos de registro con más de {0} días de antigüedad." + "TaskCleanLogsDescription": "Elimina archivos de registro con más de {0} días de antigüedad.", + "NotificationOptionApplicationUpdateAvailable": "actualización disponible", + "TaskDownloadMissingLyrics": "Descargue letras desaparecidas", + "TaskDownloadMissingLyricsDescription": "Decarga letras para canciones", + "TaskMoveTrickplayImages": "Mover localización de foto vista previa", + "NotificationOptionApplicationUpdateInstalled": "Aplicación actualización disponible", + "CleanupUserDataTask": "Tarea de limpieza de los datos del usuario" } diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index b95d07d5cf..f847d83d14 100644 --- a/Emby.Server.Implementations/Localization/Core/gsw.json +++ b/Emby.Server.Implementations/Localization/Core/gsw.json @@ -12,10 +12,10 @@ "DeviceOfflineWithName": "{0} wurde getrennt", "DeviceOnlineWithName": "{0} ist verbunden", "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}", - "Favorites": "Favoriten", + "Favorites": "Favorite", "Folders": "Ordner", "Genres": "Genre", - "HeaderAlbumArtists": "Album-Künstler", + "HeaderAlbumArtists": "Album-Künschtler", "HeaderContinueWatching": "weiter schauen", "HeaderFavoriteAlbums": "Lieblingsalben", "HeaderFavoriteArtists": "Lieblings-Künstler", diff --git a/Emby.Server.Implementations/Localization/Core/ht.json b/Emby.Server.Implementations/Localization/Core/ht.json index 4fcba99e90..f927d3173a 100644 --- a/Emby.Server.Implementations/Localization/Core/ht.json +++ b/Emby.Server.Implementations/Localization/Core/ht.json @@ -1,3 +1,62 @@ { - "Books": "liv" + "Books": "Liv", + "TasksLibraryCategory": "Libreri", + "Albums": "Albòm yo", + "Artists": "Atis yo", + "Application": "Aplikasyon", + "Channels": "Kanal yo", + "ChapterNameValue": "Chapit {0}", + "Default": "Defo", + "DeviceOnlineWithName": "{0} konekte", + "DeviceOfflineWithName": "{0} dekonekte", + "External": "Extèn", + "Collections": "Koleksyon yo", + "Favorites": "Pi Renmen", + "Folders": "Dosye", + "Genres": "Jan yo", + "Forced": "Fòse", + "HeaderAlbumArtists": "Albòm Atis", + "HeaderContinueWatching": "Kontinye Kade", + "HeaderFavoriteAlbums": "Albòm Pi Renmen", + "HeaderFavoriteArtists": "Atis Pi Renmen", + "HeaderFavoriteEpisodes": "Epizòd Pi Renmen", + "HeaderFavoriteShows": "Emisyon Pi Renmen", + "HeaderFavoriteSongs": "Mizik Pi Renmen", + "HeaderLiveTV": "Televizyon an Direk", + "HeaderNextUp": "Pwochen an", + "HomeVideos": "Videyo Lakay", + "Latest": "Pi Resan", + "MessageApplicationUpdated": "Sèvè Jellyfin met a jou", + "MessageApplicationUpdatedTo": "Sèvè Jellyfin met a jou sou {0}", + "Movies": "Fim", + "MixedContent": "Kontni Melanje", + "Music": "Mizik", + "MusicVideos": "Videyo Mizik", + "NameInstallFailed": "{0} enstalasyon fe fayit", + "NameSeasonNumber": "Sezon {0}", + "NameSeasonUnknown": "Sezon Enkoni", + "NotificationOptionCameraImageUploaded": "Imaj Kamera telechaje", + "NotificationOptionInstallationFailed": "Enstalasyon echwe", + "Photos": "Foto", + "PluginInstalledWithName": "{0} te enstale", + "PluginUninstalledWithName": "{0} te dezenstale", + "PluginUpdatedWithName": "{0} te mi a jou", + "ScheduledTaskFailedWithName": "{0} echwe", + "ScheduledTaskStartedWithName": "{0} komanse", + "Songs": "Mizik yo", + "Shows": "Emisyon yo", + "System": "Sistèm", + "TvShows": "Emisyon Tele", + "User": "Itilizatè", + "UserCreatedWithName": "Itilizatè {0} kreye", + "UserDeletedWithName": "Itilizatè {0} a efase", + "UserDownloadingItemWithValues": "{0} ap telechaje {1}", + "UserOfflineFromDevice": "{0} dekonekte de {1}", + "UserStartedPlayingItemWithValues": "{0} ap jwe {1} sou {2}", + "UserStoppedPlayingItemWithValues": "{0} fin jwe {1} sou {2}", + "UserPasswordChangedWithName": "Modpas la chanje pou Itilizatè {0}", + "ValueSpecialEpisodeName": "Spesyal - {0}", + "VersionNumber": "Vesyon {0}", + "TasksApplicationCategory": "Aplikasyon", + "TasksMaintenanceCategory": "Antretyen" } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 8baa63d89f..e73c56cb90 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -136,5 +136,7 @@ "TaskExtractMediaSegments": "Skann mediasegment", "TaskMoveTrickplayImages": "Migrer bildeplassering for Trickplay", "TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.", - "TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment." + "TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.", + "CleanupUserDataTaskDescription": "Sletter all brukerdata (avspillings-status, favoritter osv.) fra innhold som har vært utilgjengelig i minst 90 dager.", + "CleanupUserDataTask": "Oppgave for opprydding av brukerdata" } diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json index f7d1b112e1..9076b9c878 100644 --- a/Emby.Server.Implementations/Localization/Core/pr.json +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -43,5 +43,75 @@ "NameInstallFailed": "Ye couldn't bring {0} aboard yer ship", "MessageApplicationUpdatedTo": "Yer Map of the Seas has been scribbled with {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Yer Map Drawer has been rescribbled to {0}", - "MessageServerConfigurationUpdated": "Yer Map drawer has been rescribbled" + "MessageServerConfigurationUpdated": "Yer Map drawer has been rescribbled", + "Inherit": "Carry on what be passed along", + "Latest": "Newfangled", + "Movies": "Moving pictures", + "NewVersionIsAvailable": "A fresh build o’ Jellyfin Server be waitin’ fer ye to fetch.", + "NotificationOptionPluginInstalled": "Plugin nailed down", + "NotificationOptionVideoPlayback": "Video playback be underway", + "ScheduledTaskFailedWithName": "{0} ran aground", + "StartupEmbyServerIsLoading": "Jellyfin Server be preparin’ the ship. Try yer luck again soon.", + "UserOfflineFromDevice": "{0} severed ties with {1}", + "UserDownloadingItemWithValues": "{0} be haulin’ in {1}", + "UserStartedPlayingItemWithValues": "{0} be playin’ {1} aboard {2}", + "ValueHasBeenAddedToLibrary": "{0} be stashed in yer treasure trove", + "TaskCleanCacheDescription": "Wipes away cache cargo no longer called fer.", + "TaskCleanLogsDescription": "Clears the logbook o’ entries older than {0} days.", + "TaskRefreshPeopleDescription": "Refreshes the charts fer actors an’ directors in yer Treasure Trove.", + "UserLockedOutWithName": "Matey {0} be denied boarding", + "TaskAudioNormalization": "Steadyin’ the shanties", + "TaskAudioNormalizationDescription": "Scans files fer shanty steadiyin’ data.", + "HeaderRecordingGroups": "Loggin' Groups", + "MusicVideos": "Shanty films", + "Playlists": "Lists o’ plunder", + "Plugin": "Extra sail", + "NotificationOptionVideoPlaybackStopped": "Video playback dropped anchor", + "NameSeasonNumber": "Saga {0}", + "NameSeasonUnknown": "Saga be Lost", + "NotificationOptionApplicationUpdateAvailable": "A fresh build awaits", + "NotificationOptionApplicationUpdateInstalled": "App upgrade be aboard", + "NotificationOptionAudioPlayback": "Audio playback be rollin", + "NotificationOptionAudioPlaybackStopped": "Audio playback dropped anchor", + "NotificationOptionCameraImageUploaded": "Spyglass shot be hoisted", + "NotificationOptionInstallationFailed": "Install be wrecked", + "NotificationOptionNewLibraryContent": "Fresh plunder ready to claim", + "NotificationOptionPluginError": "Plugin ran aground", + "NotificationOptionPluginUninstalled": "Plugin cast overboard", + "NotificationOptionPluginUpdateInstalled": "Plugin patched ‘n ready", + "NotificationOptionServerRestartRequired": "Server be due fer a restart", + "NotificationOptionTaskFailed": "Set chore went overboard", + "TaskRefreshLibraryDescription": "Searches the Treasure Trove fer new plunder ‘n updates the charts.", + "PluginInstalledWithName": "{0} nailed down", + "TaskCleanLogs": "Swab the Log Hold", + "TaskRefreshPeople": "Freshen the Mateys", + "PluginUninstalledWithName": "{0} sent t’ Davy Jones", + "PluginUpdatedWithName": "{0} patched ‘n ready", + "ProviderValue": "Supplier o’ goods: {0}", + "ScheduledTaskStartedWithName": "{0} set sail", + "ServerNameNeedsToBeRestarted": "{0} be cravin’ a restart", + "Shows": "Sagas", + "SubtitleDownloadFailureFromForItem": "Subtitles be sunk fetchin’ from {0} fer {1}", + "Sync": "Match the tides", + "System": "The ship’s works", + "TvShows": "TV Sagas", + "Undefined": "Uncharted", + "User": "Matey", + "UserCreatedWithName": "Matey {0} joined the crew", + "UserDeletedWithName": "Matey {0} cast overboard", + "UserOnlineFromDevice": "{0} be aboard ship from {1}", + "UserPasswordChangedWithName": "New passphrase set fer Matey {0}", + "UserPolicyUpdatedWithName": "Ship rules be changed fer {0}", + "UserStoppedPlayingItemWithValues": "{0} be done playin’ {1} on {2", + "ValueSpecialEpisodeName": "Special Tale – {0}", + "VersionNumber": "Edition {0}", + "TasksMaintenanceCategory": "Hull patchin’", + "TasksLibraryCategory": "Treasure Trove", + "TasksApplicationCategory": "Ship", + "TaskCleanActivityLog": "Clear the Ship’s Log", + "TaskCleanActivityLogDescription": "Purges ship’s logs older than the chosen time.", + "TaskCleanCache": "Sweep the Cache Chest", + "TaskRefreshChapterImages": "Claim chapter portraits", + "TaskRefreshChapterImagesDescription": "Paints wee portraits fer videos that own chapters.", + "TaskRefreshLibrary": "Scan the Treasure Trove" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 84be91a872..1470a538c2 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -70,7 +70,7 @@ "ScheduledTaskFailedWithName": "{0} - неудачна", "ScheduledTaskStartedWithName": "{0} - запущена", "ServerNameNeedsToBeRestarted": "Необходим перезапуск {0}", - "Shows": "Телешоу", + "Shows": "Сериалы", "Songs": "Композиции", "StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.", "SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить", diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index af40b5e5a9..76a136cf56 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -126,5 +126,16 @@ "HearingImpaired": "ослабљен слух", "TaskAudioNormalization": "Нормализација звука", "TaskCleanCollectionsAndPlaylists": "Очистите колекције и плејлисте", - "TaskAudioNormalizationDescription": "Скенира датотеке за податке о нормализацији звука." + "TaskAudioNormalizationDescription": "Скенира датотеке за податке о нормализацији звука.", + "TaskRefreshTrickplayImages": "Направи сличице за визуелно премотавање", + "TaskRefreshTrickplayImagesDescription": "Прављење сличица које помажу код визуелног премотавања видео-снимака.", + "TaskDownloadMissingLyrics": "Преузми стихове који недостају", + "TaskCleanCollectionsAndPlaylistsDescription": "Уклања ставке које више не постоје из колекција и плејлиста.", + "TaskExtractMediaSegments": "Скенирај сегменте медија", + "TaskExtractMediaSegmentsDescription": "Извлачи или добавља сегменте медија у додацима који раде са MediaSegment-ом.", + "TaskMoveTrickplayImagesDescription": "Премешта постојеће сличице за визуелно премотавање сходно подешавањима библиотеке.", + "CleanupUserDataTask": "Задатак чишћења корисничких података", + "CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.", + "TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање", + "TaskDownloadMissingLyricsDescription": "Преузми стихове песама" } diff --git a/Emby.Server.Implementations/Localization/iso6392.txt b/Emby.Server.Implementations/Localization/iso6392.txt index 5e65bae26f..d5a7e866b8 100644 --- a/Emby.Server.Implementations/Localization/iso6392.txt +++ b/Emby.Server.Implementations/Localization/iso6392.txt @@ -402,8 +402,8 @@ sog|||Sogdian|sogdien som||so|Somali|somali son|||Songhai languages|songhai, langues sot||st|Sotho, Southern|sotho du Sud -spa||es-419|Spanish; Latin|espagnol; Latin spa||es|Spanish; Castilian|espagnol; castillan +spa||es-419|Spanish; Latin|espagnol; Latin sqi|alb|sq|Albanian|albanais srd||sc|Sardinian|sarde srn|||Sranan Tongo|sranan tongo diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 1ce363de5c..c9d76df0bf 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -314,7 +314,7 @@ namespace Emby.Server.Implementations.Playlists return; } - var newPriorItemIndex = newIndex > oldIndexAccessible ? newIndex : newIndex - 1 < 0 ? 0 : newIndex - 1; + var newPriorItemIndex = Math.Max(newIndex - 1, 0); var newPriorItemId = accessibleChildren[newPriorItemIndex].Item1.ItemId; var newPriorItemIndexOnAllChildren = children.FindIndex(c => c.Item1.ItemId.Equals(newPriorItemId)); var adjustedNewIndex = DetermineAdjustedIndex(newPriorItemIndexOnAllChildren, newIndex); diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index bf8ffaf479..92d7a3907a 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -61,7 +61,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, - IntervalTicks = TimeSpan.FromHours(24).Ticks + IntervalTicks = TimeSpan.FromHours(6).Ticks }; } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 18162ad2fc..6e4e5c7808 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -1,10 +1,14 @@ using System; +using System.Buffers; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Database.Implementations; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; +using Microsoft.EntityFrameworkCore; namespace Emby.Server.Implementations.ScheduledTasks.Tasks; @@ -15,16 +19,19 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask { private readonly ILibraryManager _libraryManager; private readonly ILocalizationManager _localization; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; /// <summary> /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class. /// </summary> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> - public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization) + /// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param> + public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory) { _libraryManager = libraryManager; _localization = localization; + _dbContextFactory = dbContextFactory; } /// <inheritdoc /> @@ -62,8 +69,61 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask } /// <inheritdoc /> - public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - return _libraryManager.ValidatePeopleAsync(progress, cancellationToken); + IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2)); + await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false); + + subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50)); + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var dupQuery = context.Peoples + .GroupBy(e => new { e.Name, e.PersonType }) + .Where(e => e.Count() > 1) + .Select(e => e.Select(f => f.Id).ToArray()); + + var total = dupQuery.Count(); + + const int PartitionSize = 100; + var iterator = 0; + int itemCounter; + var buffer = ArrayPool<Guid[]>.Shared.Rent(PartitionSize)!; + try + { + do + { + itemCounter = 0; + await foreach (var item in dupQuery + .Take(PartitionSize) + .AsAsyncEnumerable() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + buffer[itemCounter++] = item; + } + + for (int i = 0; i < itemCounter; i++) + { + var item = buffer[i]; + var reference = item[0]; + var dups = item[1..]; + await context.PeopleBaseItemMap.WhereOneOrMany(dups, e => e.PeopleId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PeopleId, reference), cancellationToken) + .ConfigureAwait(false); + await context.Peoples.Where(e => dups.Contains(e.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + subProgress.Report(100f / total * ((iterator * PartitionSize) + i)); + } + + iterator++; + } while (itemCounter == PartitionSize && !cancellationToken.IsCancellationRequested); + } + finally + { + ArrayPool<Guid[]>.Shared.Return(buffer); + } + + subProgress.Report(100); + } } } diff --git a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs index 01c1e596f9..86d08ed27b 100644 --- a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs @@ -6,7 +6,6 @@ using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { @@ -54,7 +53,7 @@ namespace Emby.Server.Implementations.Sorting /// <returns>DateTime.</returns> private int GetValue(BaseItem x) { - return x.IsFavoriteOrLiked(User) ? 0 : 1; + return x.IsFavoriteOrLiked(User, userItemData: null) ? 0 : 1; } } } diff --git a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs index 6f206c8772..9faa02f1fd 100644 --- a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs @@ -7,7 +7,6 @@ using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { @@ -55,7 +54,7 @@ namespace Emby.Server.Implementations.Sorting /// <returns>DateTime.</returns> private int GetValue(BaseItem x) { - return x.IsPlayed(User) ? 0 : 1; + return x.IsPlayed(User, userItemData: null) ? 0 : 1; } } } diff --git a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs index fd1326327b..6f177c4637 100644 --- a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs @@ -7,7 +7,6 @@ using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { @@ -55,7 +54,7 @@ namespace Emby.Server.Implementations.Sorting /// <returns>DateTime.</returns> private int GetValue(BaseItem x) { - return x.IsUnplayed(User) ? 0 : 1; + return x.IsUnplayed(User, userItemData: null) ? 0 : 1; } } } diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 13064882cc..585318d245 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -96,9 +96,6 @@ public class DisplayPreferencesController : BaseJellyfinApiController dto.CustomPrefs.TryAdd(key, value); } - // This will essentially be a noop if no changes have been made, but new prefs must be saved at least. - _displayPreferencesManager.SaveChanges(); - return dto; } @@ -210,8 +207,8 @@ public class DisplayPreferencesController : BaseJellyfinApiController // Set all remaining custom preferences. _displayPreferencesManager.SetCustomItemDisplayPreferences(userId.Value, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); - _displayPreferencesManager.SaveChanges(); - + _displayPreferencesManager.UpdateItemDisplayPreferences(itemPrefs); + _displayPreferencesManager.UpdateDisplayPreferences(existingDisplayPreferences); return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index b18d7e05d4..4c9cc2b1e8 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -779,12 +779,14 @@ public class LibraryController : BaseJellyfinApiController var query = new InternalItemsQuery(user) { Genres = item.Genres, + Tags = item.Tags, Limit = limit, IncludeItemTypes = includeItemTypes.ToArray(), DtoOptions = dtoOptions, EnableTotalRecordCount = !isMovie ?? true, EnableGroupByMetadataKey = isMovie ?? false, - ExcludeItemIds = [itemId] + ExcludeItemIds = [itemId], + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] }; // ExcludeArtistIds diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs index b602585863..5495f60d88 100644 --- a/Jellyfin.Api/Controllers/YearsController.cs +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -108,6 +108,7 @@ public class YearsController : BaseJellyfinApiController bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes); IReadOnlyList<BaseItem> items; + int totalCount = -1; if (parentItem.IsFolder) { var folder = (Folder)parentItem; @@ -118,7 +119,7 @@ public class YearsController : BaseJellyfinApiController } else { - items = recursive ? folder.GetRecursiveChildren(user, query) : folder.GetChildren(user, true).Where(Filter).ToArray(); + items = recursive ? folder.GetRecursiveChildren(user, query, out totalCount) : folder.GetChildren(user, true).Where(Filter).ToArray(); } } else @@ -153,7 +154,7 @@ public class YearsController : BaseJellyfinApiController var result = new QueryResult<BaseItemDto>( startIndex, - ibnItemsArray.Count, + totalCount == -1 ? ibnItemsArray.Count : totalCount, dtos.Where(i => i is not null).ToArray()); return result; } diff --git a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs index 9d149cc85a..143d82bac6 100644 --- a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Jellyfin.Data; using Jellyfin.Database.Implementations.Enums; @@ -57,6 +58,21 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume } /// <inheritdoc /> + protected override Task<IEnumerable<SessionInfo>> GetDataToSendForConnection(IWebSocketConnection connection) + { + // For non-admin users, filter the sessions to only include their own sessions + if (connection.AuthorizationInfo?.User is not null && + !connection.AuthorizationInfo.IsApiKey && + !connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) + { + var userId = connection.AuthorizationInfo.User.Id; + return Task.FromResult(_sessionManager.Sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId))); + } + + return Task.FromResult(_sessionManager.Sessions); + } + + /// <inheritdoc /> protected override async ValueTask DisposeAsyncCore() { if (!_disposed) @@ -80,11 +96,10 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume /// <param name="message">The message.</param> protected override void Start(WebSocketMessageInfo message) { - if (!message.Connection.AuthorizationInfo.IsApiKey - && (message.Connection.AuthorizationInfo.User is null - || !message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))) + // Allow all authenticated users to subscribe to session information + if (message.Connection.AuthorizationInfo.User is null && !message.Connection.AuthorizationInfo.IsApiKey) { - throw new AuthenticationException("Only admin users can subscribe to session information."); + throw new AuthenticationException("User must be authenticated to subscribe to session Information."); } base.Start(message); diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index 74d99455df..e5c3cef3d3 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -39,7 +39,7 @@ public class BackupService : IBackupService ReferenceHandler = ReferenceHandler.IgnoreCycles, }; - private readonly Version _backupEngineVersion = Version.Parse("0.2.0"); + private readonly Version _backupEngineVersion = new Version(0, 2, 0); /// <summary> /// Initializes a new instance of the <see cref="BackupService"/> class. diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index cf4f405ee5..4d17e37699 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -99,11 +99,11 @@ public sealed class BaseItemRepository } /// <inheritdoc /> - public void DeleteItem(Guid id) + public void DeleteItem(params IReadOnlyList<Guid> ids) { - if (id.IsEmpty() || id.Equals(PlaceholderId)) + if (ids is null || ids.Count == 0 || ids.Any(f => f.Equals(PlaceholderId))) { - throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(id)); + throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(ids)); } using var context = _dbProvider.CreateDbContext(); @@ -111,13 +111,15 @@ public sealed class BaseItemRepository var date = (DateTime?)DateTime.UtcNow; + var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray(); + // Remove any UserData entries for the placeholder item that would conflict with the UserData // being detached from the item being deleted. This is necessary because, during an update, // UserData may be reattached to a new entry, but some entries can be left behind. // Ensures there are no duplicate UserId/CustomDataKey combinations for the placeholder. context.UserData .Join( - context.UserData.Where(e => e.ItemId == id), + context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId), placeholder => new { placeholder.UserId, placeholder.CustomDataKey }, userData => new { userData.UserId, userData.CustomDataKey }, (placeholder, userData) => placeholder) @@ -125,29 +127,31 @@ public sealed class BaseItemRepository .ExecuteDelete(); // Detach all user watch data - context.UserData.Where(e => e.ItemId == id) + context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId) .ExecuteUpdate(e => e .SetProperty(f => f.RetentionDate, date) .SetProperty(f => f.ItemId, PlaceholderId)); - context.AncestorIds.Where(e => e.ItemId == id || e.ParentItemId == id).ExecuteDelete(); - context.AttachmentStreamInfos.Where(e => e.ItemId == id).ExecuteDelete(); - context.BaseItemImageInfos.Where(e => e.ItemId == id).ExecuteDelete(); - context.BaseItemMetadataFields.Where(e => e.ItemId == id).ExecuteDelete(); - context.BaseItemProviders.Where(e => e.ItemId == id).ExecuteDelete(); - context.BaseItemTrailerTypes.Where(e => e.ItemId == id).ExecuteDelete(); - context.BaseItems.Where(e => e.Id == id).ExecuteDelete(); - context.Chapters.Where(e => e.ItemId == id).ExecuteDelete(); - context.CustomItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete(); - context.ItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete(); + context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDelete(); + context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete(); + context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete(); - context.ItemValuesMap.Where(e => e.ItemId == id).ExecuteDelete(); - context.KeyframeData.Where(e => e.ItemId == id).ExecuteDelete(); - context.MediaSegments.Where(e => e.ItemId == id).ExecuteDelete(); - context.MediaStreamInfos.Where(e => e.ItemId == id).ExecuteDelete(); - context.PeopleBaseItemMap.Where(e => e.ItemId == id).ExecuteDelete(); - context.Peoples.Where(e => e.BaseItems!.Count == 0).ExecuteDelete(); - context.TrickplayInfos.Where(e => e.ItemId == id).ExecuteDelete(); + context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray(); + context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete(); + context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.SaveChanges(); transaction.Commit(); } @@ -267,7 +271,7 @@ public sealed class BaseItemRepository result.TotalRecordCount = dbQuery.Count(); } - dbQuery = ApplyGroupingFilter(dbQuery, filter); + dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); @@ -286,7 +290,7 @@ public sealed class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); - dbQuery = ApplyGroupingFilter(dbQuery, filter); + dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); @@ -328,7 +332,7 @@ public sealed class BaseItemRepository var mainquery = PrepareItemQuery(context, filter); mainquery = TranslateQuery(mainquery, context, filter); mainquery = mainquery.Where(g => g.DateCreated >= subqueryGrouped.Min(s => s.MaxDateCreated)); - mainquery = ApplyGroupingFilter(mainquery, filter); + mainquery = ApplyGroupingFilter(context, mainquery, filter); mainquery = ApplyQueryPaging(mainquery, filter); return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); @@ -365,37 +369,53 @@ public sealed class BaseItemRepository return query.ToArray(); } - private IQueryable<BaseItemEntity> ApplyGroupingFilter(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) + private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { // This whole block is needed to filter duplicate entries on request // for the time being it cannot be used because it would destroy the ordering // this results in "duplicate" responses for queries that try to lookup individual series or multiple versions but // for that case the invoker has to run a DistinctBy(e => e.PresentationUniqueKey) on their own - // var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter); - // if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey) - // { - // dbQuery = ApplyOrder(dbQuery, filter); - // dbQuery = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.First()); - // } - // else if (enableGroupByPresentationUniqueKey) - // { - // dbQuery = ApplyOrder(dbQuery, filter); - // dbQuery = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.First()); - // } - // else if (filter.GroupBySeriesPresentationUniqueKey) - // { - // dbQuery = ApplyOrder(dbQuery, filter); - // dbQuery = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.First()); - // } - // else - // { - // dbQuery = dbQuery.Distinct(); - // dbQuery = ApplyOrder(dbQuery, filter); - // } - dbQuery = dbQuery.Distinct(); + var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter); + if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey) + { + var tempQuery = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.FirstOrDefault()).Select(e => e!.Id); + dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id)); + } + else if (enableGroupByPresentationUniqueKey) + { + var tempQuery = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.FirstOrDefault()).Select(e => e!.Id); + dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id)); + } + else if (filter.GroupBySeriesPresentationUniqueKey) + { + var tempQuery = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.FirstOrDefault()).Select(e => e!.Id); + dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id)); + } + else + { + dbQuery = dbQuery.Distinct(); + } + dbQuery = ApplyOrder(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter); + + return dbQuery; + } + + private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) + { + dbQuery = dbQuery.Include(e => e.TrailerTypes) + .Include(e => e.Provider) + .Include(e => e.LockedFields) + .Include(e => e.UserData); + + if (filter.DtoOptions.EnableImages) + { + dbQuery = dbQuery.Include(e => e.Images); + } + return dbQuery; } @@ -422,8 +442,7 @@ public sealed class BaseItemRepository private IQueryable<BaseItemEntity> ApplyQueryFilter(IQueryable<BaseItemEntity> dbQuery, JellyfinDbContext context, InternalItemsQuery filter) { dbQuery = TranslateQuery(dbQuery, context, filter); - dbQuery = ApplyOrder(dbQuery, filter); - dbQuery = ApplyGroupingFilter(dbQuery, filter); + dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); return dbQuery; } @@ -431,15 +450,7 @@ public sealed class BaseItemRepository private IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter) { IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking(); - dbQuery = dbQuery.AsSingleQuery() - .Include(e => e.TrailerTypes) - .Include(e => e.Provider) - .Include(e => e.LockedFields); - - if (filter.DtoOptions.EnableImages) - { - dbQuery = dbQuery.Include(e => e.Images); - } + dbQuery = dbQuery.AsSingleQuery(); return dbQuery; } @@ -470,7 +481,7 @@ public sealed class BaseItemRepository var counts = dbQuery .GroupBy(x => x.Type) .Select(x => new { x.Key, Count = x.Count() }) - .AsEnumerable(); + .ToArray(); var lookup = _itemTypeLookup.BaseItemKindNames; var result = new ItemCounts(); @@ -724,13 +735,20 @@ public sealed class BaseItemRepository } using var context = _dbProvider.CreateDbContext(); - var item = PrepareItemQuery(context, new() + var dbQuery = PrepareItemQuery(context, new() { DtoOptions = new() { EnableImages = true } - }).FirstOrDefault(e => e.Id == id); + }); + dbQuery = dbQuery.Include(e => e.TrailerTypes) + .Include(e => e.Provider) + .Include(e => e.LockedFields) + .Include(e => e.UserData) + .Include(e => e.Images); + + var item = dbQuery.FirstOrDefault(e => e.Id == id); if (item is null) { return null; @@ -745,8 +763,9 @@ public sealed class BaseItemRepository /// <param name="entity">The entity.</param> /// <param name="dto">The dto base instance.</param> /// <param name="appHost">The Application server Host.</param> + /// <param name="logger">The applogger.</param> /// <returns>The dto to map.</returns> - public static BaseItemDto Map(BaseItemEntity entity, BaseItemDto dto, IServerApplicationHost? appHost) + public static BaseItemDto Map(BaseItemEntity entity, BaseItemDto dto, IServerApplicationHost? appHost, ILogger logger) { dto.Id = entity.Id; dto.ParentId = entity.ParentId.GetValueOrDefault(); @@ -791,6 +810,8 @@ public sealed class BaseItemRepository dto.OwnerId = string.IsNullOrWhiteSpace(entity.OwnerId) ? Guid.Empty : (Guid.TryParse(entity.OwnerId, out var ownerId) ? ownerId : Guid.Empty); dto.Width = entity.Width.GetValueOrDefault(); dto.Height = entity.Height.GetValueOrDefault(); + dto.UserData = entity.UserData; + if (entity.Provider is not null) { dto.ProviderIds = entity.Provider.ToDictionary(e => e.ProviderId, e => e.ProviderValue); @@ -1144,7 +1165,7 @@ public sealed class BaseItemRepository dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialize unknown type."); } - return Map(baseItemEntity, dto, appHost); + return Map(baseItemEntity, dto, appHost, logger); } private QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType) @@ -1302,7 +1323,13 @@ public sealed class BaseItemRepository result.Items = [ .. query - .Select(e => e.First()) + .Select(e => e.AsQueryable() + .Include(e => e.TrailerTypes) + .Include(e => e.Provider) + .Include(e => e.LockedFields) + .Include(e => e.Images) + .AsSingleQuery() + .First()) .AsEnumerable() .Where(e => e is not null) .Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e => @@ -1884,7 +1911,7 @@ public sealed class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.NameStartsWith)) { - baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith) || e.Name!.StartsWith(filter.NameStartsWith)); + baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith)); } if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) @@ -2027,22 +2054,26 @@ public sealed class BaseItemRepository if (filter.MinParentalRating != null) { var min = filter.MinParentalRating; - minParentalRatingFilter = e => e.InheritedParentalRatingValue >= min.Score || e.InheritedParentalRatingValue == null; - if (min.SubScore != null) - { - minParentalRatingFilter = minParentalRatingFilter.And(e => e.InheritedParentalRatingValue >= min.SubScore || e.InheritedParentalRatingValue == null); - } + var minScore = min.Score; + var minSubScore = min.SubScore ?? 0; + + minParentalRatingFilter = e => + e.InheritedParentalRatingValue == null || + e.InheritedParentalRatingValue > minScore || + (e.InheritedParentalRatingValue == minScore && (e.InheritedParentalRatingSubValue ?? 0) >= minSubScore); } Expression<Func<BaseItemEntity, bool>>? maxParentalRatingFilter = null; if (filter.MaxParentalRating != null) { var max = filter.MaxParentalRating; - maxParentalRatingFilter = e => e.InheritedParentalRatingValue <= max.Score || e.InheritedParentalRatingValue == null; - if (max.SubScore != null) - { - maxParentalRatingFilter = maxParentalRatingFilter.And(e => e.InheritedParentalRatingValue <= max.SubScore || e.InheritedParentalRatingValue == null); - } + var maxScore = max.Score; + var maxSubScore = max.SubScore ?? 0; + + maxParentalRatingFilter = e => + e.InheritedParentalRatingValue == null || + e.InheritedParentalRatingValue < maxScore || + (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore); } if (filter.HasParentalRating ?? false) @@ -2270,8 +2301,18 @@ public sealed class BaseItemRepository if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) { - var include = filter.HasAnyProviderId.Select(e => $"{e.Key}:{e.Value}").ToArray(); - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => include.Contains(f))); + // Allow setting a null or empty value to get all items that have the specified provider set. + var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray(); + if (includeAny.Length > 0) + { + baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId))); + } + + var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray(); + if (includeSelected.Length > 0) + { + baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f))); + } } if (filter.HasImdbId.HasValue) @@ -2449,4 +2490,68 @@ public sealed class BaseItemRepository return await dbContext.BaseItems.AnyAsync(f => f.Id == id).ConfigureAwait(false); } } + + /// <inheritdoc/> + public bool GetIsPlayed(User user, Guid id, bool recursive) + { + using var dbContext = _dbProvider.CreateDbContext(); + + if (recursive) + { + var folderList = TraverseHirachyDown(id, dbContext, item => (item.IsFolder || item.IsVirtualItem)); + + return dbContext.BaseItems + .Where(e => folderList.Contains(e.ParentId!.Value) && !e.IsFolder && !e.IsVirtualItem) + .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + } + + return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + } + + private static HashSet<Guid> TraverseHirachyDown(Guid parentId, JellyfinDbContext dbContext, Expression<Func<BaseItemEntity, bool>>? filter = null) + { + var folderStack = new HashSet<Guid>() + { + parentId + }; + var folderList = new HashSet<Guid>() + { + parentId + }; + + while (folderStack.Count != 0) + { + var items = folderStack.ToArray(); + folderStack.Clear(); + var query = dbContext.BaseItems + .WhereOneOrMany(items, e => e.ParentId!.Value); + + if (filter != null) + { + query = query.Where(filter); + } + + foreach (var item in query.Select(e => e.Id).ToArray()) + { + if (folderList.Add(item)) + { + folderStack.Add(item); + } + } + } + + return folderList; + } + + /// <inheritdoc/> + public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames) + { + using var dbContext = _dbProvider.CreateDbContext(); + + var artists = dbContext.BaseItems.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!) + .Where(e => artistNames.Contains(e.Name)) + .ToArray(); + + return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray()); + } } diff --git a/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs index 93c6f472e2..438458c6be 100644 --- a/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs +++ b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs @@ -55,11 +55,14 @@ public class KeyframeRepository : IKeyframeRepository public async Task SaveKeyframeDataAsync(Guid itemId, MediaEncoding.Keyframes.KeyframeData data, CancellationToken cancellationToken) { using var context = _dbProvider.CreateDbContext(); - using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); - await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); - await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false); - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await using (transaction.ConfigureAwait(false)) + { + await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } } /// <inheritdoc /> diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index be58e2a527..e03c136915 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -35,16 +35,22 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I using var context = _dbProvider.CreateDbContext(); var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter); - // dbQuery = dbQuery.OrderBy(e => e.ListOrder); - if (filter.Limit > 0) + // Include PeopleBaseItemMap + if (!filter.ItemId.IsEmpty()) { - dbQuery = dbQuery.Take(filter.Limit); + dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId)) + .OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder) + .ThenBy(e => e.PersonType) + .ThenBy(e => e.Name); + } + else + { + dbQuery = dbQuery.OrderBy(e => e.Name); } - // Include PeopleBaseItemMap - if (!filter.ItemId.IsEmpty()) + if (filter.Limit > 0) { - dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId)); + dbQuery = dbQuery.Take(filter.Limit); } return dbQuery.AsEnumerable().Select(Map).ToArray(); @@ -68,19 +74,42 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I /// <inheritdoc /> public void UpdatePeople(Guid itemId, IReadOnlyList<PersonInfo> people) { - using var context = _dbProvider.CreateDbContext(); + foreach (var item in people.Where(e => e.Role is null)) + { + item.Role = string.Empty; + } - // TODO: yes for __SOME__ reason there can be duplicates. - people = people.DistinctBy(e => e.Id).ToArray(); - var personids = people.Select(f => f.Id); - var existingPersons = context.Peoples.Where(p => personids.Contains(p.Id)).Select(f => f.Id).ToArray(); - context.Peoples.AddRange(people.Where(e => !existingPersons.Contains(e.Id)).Select(Map)); + // multiple metadata providers can provide the _same_ person + people = people.DistinctBy(e => e.Name + "-" + e.Type).ToArray(); + var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray(); + + using var context = _dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + var existingPersons = context.Peoples.Select(e => new + { + item = e, + SelectionKey = e.Name + "-" + e.PersonType + }) + .Where(p => personKeys.Contains(p.SelectionKey)) + .Select(f => f.item) + .ToArray(); + + var toAdd = people + .Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString())) + .Select(Map); + context.Peoples.AddRange(toAdd); context.SaveChanges(); - var maps = context.PeopleBaseItemMap.Where(e => e.ItemId == itemId).ToList(); + var personsEntities = toAdd.Concat(existingPersons).ToArray(); + + var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList(); + + var listOrder = 0; + foreach (var person in people) { - var existingMap = maps.FirstOrDefault(e => e.PeopleId == person.Id); + var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString()); + var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); if (existingMap is null) { context.PeopleBaseItemMap.Add(new PeopleBaseItemMap() @@ -88,22 +117,28 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I Item = null!, ItemId = itemId, People = null!, - PeopleId = person.Id, - ListOrder = person.SortOrder, + PeopleId = entityPerson.Id, + ListOrder = listOrder, SortOrder = person.SortOrder, Role = person.Role }); } else { + // Update the order for existing mappings + existingMap.ListOrder = listOrder; + existingMap.SortOrder = person.SortOrder; // person mapping already exists so remove from list - maps.Remove(existingMap); + existingMaps.Remove(existingMap); } + + listOrder++; } - context.PeopleBaseItemMap.RemoveRange(maps); + context.PeopleBaseItemMap.RemoveRange(existingMaps); context.SaveChanges(); + transaction.Commit(); } private PersonInfo Map(People people) diff --git a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs index 97c9d79f53..d00c87463c 100644 --- a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs +++ b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs @@ -68,87 +68,89 @@ public class MediaSegmentManager : IMediaSegmentManager return; } - using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - - _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count); - - if (forceOverwrite) - { - // delete all existing media segments if forceOverwrite is set. - await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); - } - - foreach (var provider in providers) + var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (db.ConfigureAwait(false)) { - if (!await provider.Supports(baseItem).ConfigureAwait(false)) - { - _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path); - continue; - } + _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count); - IQueryable<MediaSegment> existingSegments; if (forceOverwrite) { - existingSegments = Array.Empty<MediaSegment>().AsQueryable(); - } - else - { - existingSegments = db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id) && e.SegmentProviderId == GetProviderId(provider.Name)); + // delete all existing media segments if forceOverwrite is set. + await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); } - var requestItem = new MediaSegmentGenerationRequest() + foreach (var provider in providers) { - ItemId = baseItem.Id, - ExistingSegments = existingSegments.Select(e => Map(e)).ToArray() - }; + if (!await provider.Supports(baseItem).ConfigureAwait(false)) + { + _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path); + continue; + } - try - { - var segments = await provider.GetMediaSegments(requestItem, cancellationToken) - .ConfigureAwait(false); + IQueryable<MediaSegment> existingSegments; + if (forceOverwrite) + { + existingSegments = Array.Empty<MediaSegment>().AsQueryable(); + } + else + { + existingSegments = db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id) && e.SegmentProviderId == GetProviderId(provider.Name)); + } + + var requestItem = new MediaSegmentGenerationRequest() + { + ItemId = baseItem.Id, + ExistingSegments = existingSegments.Select(e => Map(e)).ToArray() + }; - if (!forceOverwrite) + try { - var existingSegmentsList = existingSegments.ToArray(); // Cannot use requestItem's list, as the provider might tamper with its items. - if (segments.Count == requestItem.ExistingSegments.Count && segments.All(e => existingSegmentsList.Any(f => + var segments = await provider.GetMediaSegments(requestItem, cancellationToken) + .ConfigureAwait(false); + + if (!forceOverwrite) + { + var existingSegmentsList = existingSegments.ToArray(); // Cannot use requestItem's list, as the provider might tamper with its items. + if (segments.Count == requestItem.ExistingSegments.Count && segments.All(e => existingSegmentsList.Any(f => + { + return + e.StartTicks == f.StartTicks && + e.EndTicks == f.EndTicks && + e.Type == f.Type; + }))) + { + _logger.LogDebug("Media Segment provider {ProviderName} did not modify any segments for {MediaPath}", provider.Name, baseItem.Path); + continue; + } + + // delete existing media segments that were re-generated. + await existingSegments.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } + + if (segments.Count == 0 && !requestItem.ExistingSegments.Any()) { - return - e.StartTicks == f.StartTicks && - e.EndTicks == f.EndTicks && - e.Type == f.Type; - }))) + _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path); + continue; + } + else if (segments.Count == 0 && requestItem.ExistingSegments.Any()) { - _logger.LogDebug("Media Segment provider {ProviderName} did not modify any segments for {MediaPath}", provider.Name, baseItem.Path); + _logger.LogDebug("Media Segment provider {ProviderName} deleted all segments for {MediaPath}", provider.Name, baseItem.Path); continue; } - // delete existing media segments that were re-generated. - await existingSegments.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); - } - - if (segments.Count == 0 && !requestItem.ExistingSegments.Any()) - { - _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path); - continue; - } - else if (segments.Count == 0 && requestItem.ExistingSegments.Any()) - { - _logger.LogDebug("Media Segment provider {ProviderName} deleted all segments for {MediaPath}", provider.Name, baseItem.Path); - continue; + _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path); + var providerId = GetProviderId(provider.Name); + foreach (var segment in segments) + { + segment.ItemId = baseItem.Id; + await CreateSegmentAsync(segment, providerId).ConfigureAwait(false); + } } - - _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path); - var providerId = GetProviderId(provider.Name); - foreach (var segment in segments) + catch (Exception ex) { - segment.ItemId = baseItem.Id; - await CreateSegmentAsync(segment, providerId).ConfigureAwait(false); + _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path); } } - catch (Exception ex) - { - _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path); - } } } @@ -157,24 +159,34 @@ public class MediaSegmentManager : IMediaSegmentManager { ArgumentOutOfRangeException.ThrowIfLessThan(mediaSegment.EndTicks, mediaSegment.StartTicks); - using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - db.MediaSegments.Add(Map(mediaSegment, segmentProviderId)); - await db.SaveChangesAsync().ConfigureAwait(false); + var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (db.ConfigureAwait(false)) + { + db.MediaSegments.Add(Map(mediaSegment, segmentProviderId)); + await db.SaveChangesAsync().ConfigureAwait(false); + } + return mediaSegment; } /// <inheritdoc /> public async Task DeleteSegmentAsync(Guid segmentId) { - using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await db.MediaSegments.Where(e => e.Id.Equals(segmentId)).ExecuteDeleteAsync().ConfigureAwait(false); + var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (db.ConfigureAwait(false)) + { + await db.MediaSegments.Where(e => e.Id.Equals(segmentId)).ExecuteDeleteAsync().ConfigureAwait(false); + } } /// <inheritdoc /> public async Task DeleteSegmentsAsync(Guid itemId, CancellationToken cancellationToken) { - using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - await db.MediaSegments.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (db.ConfigureAwait(false)) + { + await db.MediaSegments.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } } /// <inheritdoc /> @@ -186,36 +198,38 @@ public class MediaSegmentManager : IMediaSegmentManager return []; } - using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - - var query = db.MediaSegments - .Where(e => e.ItemId.Equals(item.Id)); - - if (typeFilter is not null) + var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (db.ConfigureAwait(false)) { - query = query.Where(e => typeFilter.Contains(e.Type)); - } + var query = db.MediaSegments + .Where(e => e.ItemId.Equals(item.Id)); - if (filterByProvider) - { - var providerIds = _segmentProviders - .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) - .Select(f => GetProviderId(f.Name)) - .ToArray(); - if (providerIds.Length == 0) + if (typeFilter is not null) { - return []; + query = query.Where(e => typeFilter.Contains(e.Type)); } - query = query.Where(e => providerIds.Contains(e.SegmentProviderId)); - } + if (filterByProvider) + { + var providerIds = _segmentProviders + .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) + .Select(f => GetProviderId(f.Name)) + .ToArray(); + if (providerIds.Length == 0) + { + return []; + } - return query - .OrderBy(e => e.StartTicks) - .AsNoTracking() - .AsEnumerable() - .Select(Map) - .ToArray(); + query = query.Where(e => providerIds.Contains(e.SegmentProviderId)); + } + + return query + .OrderBy(e => e.StartTicks) + .AsNoTracking() + .AsEnumerable() + .Select(Map) + .ToArray(); + } } private static MediaSegmentDto Map(MediaSegment segment) diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs index 0f21e11a35..0e126fe9a0 100644 --- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs +++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs @@ -1,109 +1,116 @@ -#pragma warning disable CA1307 -#pragma warning disable CA1309 - using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller; using Microsoft.EntityFrameworkCore; -namespace Jellyfin.Server.Implementations.Users +namespace Jellyfin.Server.Implementations.Users; + +/// <summary> +/// Manages the storage and retrieval of display preferences through Entity Framework. +/// </summary> +public sealed class DisplayPreferencesManager : IDisplayPreferencesManager { + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + /// <summary> - /// Manages the storage and retrieval of display preferences through Entity Framework. + /// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class. /// </summary> - public sealed class DisplayPreferencesManager : IDisplayPreferencesManager, IAsyncDisposable + /// <param name="dbContextFactory">The database context factory.</param> + public DisplayPreferencesManager(IDbContextFactory<JellyfinDbContext> dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + /// <inheritdoc /> + public DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client) { - private readonly JellyfinDbContext _dbContext; + using var dbContext = _dbContextFactory.CreateDbContext(); + var prefs = dbContext.DisplayPreferences + .Include(pref => pref.HomeSections) + .FirstOrDefault(pref => + pref.UserId.Equals(userId) && pref.Client == client && pref.ItemId.Equals(itemId)); - /// <summary> - /// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class. - /// </summary> - /// <param name="dbContextFactory">The database context factory.</param> - public DisplayPreferencesManager(IDbContextFactory<JellyfinDbContext> dbContextFactory) + if (prefs is null) { - _dbContext = dbContextFactory.CreateDbContext(); + prefs = new DisplayPreferences(userId, itemId, client); + dbContext.DisplayPreferences.Add(prefs); + dbContext.SaveChanges(); } - /// <inheritdoc /> - public DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client) + return prefs; + } + + /// <inheritdoc /> + public ItemDisplayPreferences GetItemDisplayPreferences(Guid userId, Guid itemId, string client) + { + using var dbContext = _dbContextFactory.CreateDbContext(); + var prefs = dbContext.ItemDisplayPreferences + .FirstOrDefault(pref => pref.UserId.Equals(userId) && pref.ItemId.Equals(itemId) && pref.Client == client); + + if (prefs is null) { - var prefs = _dbContext.DisplayPreferences - .Include(pref => pref.HomeSections) - .FirstOrDefault(pref => - pref.UserId.Equals(userId) && string.Equals(pref.Client, client) && pref.ItemId.Equals(itemId)); - - if (prefs is null) - { - prefs = new DisplayPreferences(userId, itemId, client); - _dbContext.DisplayPreferences.Add(prefs); - } - - return prefs; + prefs = new ItemDisplayPreferences(userId, Guid.Empty, client); + dbContext.ItemDisplayPreferences.Add(prefs); + dbContext.SaveChanges(); } - /// <inheritdoc /> - public ItemDisplayPreferences GetItemDisplayPreferences(Guid userId, Guid itemId, string client) - { - var prefs = _dbContext.ItemDisplayPreferences - .FirstOrDefault(pref => pref.UserId.Equals(userId) && pref.ItemId.Equals(itemId) && string.Equals(pref.Client, client)); + return prefs; + } - if (prefs is null) - { - prefs = new ItemDisplayPreferences(userId, Guid.Empty, client); - _dbContext.ItemDisplayPreferences.Add(prefs); - } + /// <inheritdoc /> + public IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client) + { + using var dbContext = _dbContextFactory.CreateDbContext(); + return dbContext.ItemDisplayPreferences + .Where(prefs => prefs.UserId.Equals(userId) && !prefs.ItemId.Equals(default) && prefs.Client == client) + .ToList(); + } - return prefs; - } + /// <inheritdoc /> + public Dictionary<string, string?> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client) + { + using var dbContext = _dbContextFactory.CreateDbContext(); + return dbContext.CustomItemDisplayPreferences + .Where(prefs => prefs.UserId.Equals(userId) + && prefs.ItemId.Equals(itemId) + && prefs.Client == client) + .ToDictionary(prefs => prefs.Key, prefs => prefs.Value); + } - /// <inheritdoc /> - public IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client) - { - return _dbContext.ItemDisplayPreferences - .Where(prefs => prefs.UserId.Equals(userId) && !prefs.ItemId.Equals(default) && string.Equals(prefs.Client, client)) - .ToList(); - } + /// <inheritdoc /> + public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences) + { + using var dbContext = _dbContextFactory.CreateDbContext(); + dbContext.CustomItemDisplayPreferences.Where(prefs => prefs.UserId.Equals(userId) + && prefs.ItemId.Equals(itemId) + && prefs.Client == client) + .ExecuteDelete(); - /// <inheritdoc /> - public Dictionary<string, string?> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client) + foreach (var (key, value) in customPreferences) { - return _dbContext.CustomItemDisplayPreferences - .Where(prefs => prefs.UserId.Equals(userId) - && prefs.ItemId.Equals(itemId) - && string.Equals(prefs.Client, client)) - .ToDictionary(prefs => prefs.Key, prefs => prefs.Value); + dbContext.CustomItemDisplayPreferences + .Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value)); } - /// <inheritdoc /> - public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences) - { - var existingPrefs = _dbContext.CustomItemDisplayPreferences - .Where(prefs => prefs.UserId.Equals(userId) - && prefs.ItemId.Equals(itemId) - && string.Equals(prefs.Client, client)); - _dbContext.CustomItemDisplayPreferences.RemoveRange(existingPrefs); - - foreach (var (key, value) in customPreferences) - { - _dbContext.CustomItemDisplayPreferences - .Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value)); - } - } + dbContext.SaveChanges(); + } - /// <inheritdoc /> - public void SaveChanges() - { - _dbContext.SaveChanges(); - } + /// <inheritdoc/> + public void UpdateDisplayPreferences(DisplayPreferences displayPreferences) + { + using var dbContext = _dbContextFactory.CreateDbContext(); + dbContext.DisplayPreferences.Attach(displayPreferences).State = EntityState.Modified; + dbContext.SaveChanges(); + } - /// <inheritdoc /> - public async ValueTask DisposeAsync() - { - await _dbContext.DisposeAsync().ConfigureAwait(false); - } + /// <inheritdoc/> + public void UpdateItemDisplayPreferences(ItemDisplayPreferences itemDisplayPreferences) + { + using var dbContext = _dbContextFactory.CreateDbContext(); + dbContext.ItemDisplayPreferences.Attach(itemDisplayPreferences).State = EntityState.Modified; + dbContext.SaveChanges(); } } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 3dfb14d716..d0b41a7f6b 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -272,6 +272,7 @@ namespace Jellyfin.Server.Implementations.Users var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { + dbContext.Users.Attach(user); dbContext.Users.Remove(user); await dbContext.SaveChangesAsync().ConfigureAwait(false); } @@ -887,7 +888,8 @@ namespace Jellyfin.Server.Implementations.Users private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) { - dbContext.Users.Update(user); + dbContext.Users.Attach(user); + dbContext.Entry(user).State = EntityState.Modified; _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index f3bf6b805a..2548ddea7c 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -84,7 +84,7 @@ namespace Jellyfin.Server serviceCollection.AddSingleton<IAuthenticationProvider, DefaultAuthenticationProvider>(); serviceCollection.AddSingleton<IAuthenticationProvider, InvalidAuthProvider>(); serviceCollection.AddSingleton<IPasswordResetProvider, DefaultPasswordResetProvider>(); - serviceCollection.AddScoped<IDisplayPreferencesManager, DisplayPreferencesManager>(); + serviceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>(); serviceCollection.AddSingleton<IDeviceManager, DeviceManager>(); serviceCollection.AddSingleton<ITrickplayManager, TrickplayManager>(); diff --git a/Jellyfin.Server/Filters/AdditionalModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs index 421eeecda0..58d37db5a5 100644 --- a/Jellyfin.Server/Filters/AdditionalModelFilter.cs +++ b/Jellyfin.Server/Filters/AdditionalModelFilter.cs @@ -175,7 +175,7 @@ namespace Jellyfin.Server.Filters // Manually generate sync play GroupUpdate messages. var groupUpdateTypes = typeof(GroupUpdate<>).Assembly.GetTypes() - .Where(t => t.BaseType != null + .Where(t => t.BaseType is not null && t.BaseType.IsGenericType && t.BaseType.GetGenericTypeDefinition() == typeof(GroupUpdate<>)) .ToList(); diff --git a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs index fef5577a13..08caac0d38 100644 --- a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs +++ b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Net.Http.Headers; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; @@ -10,27 +8,44 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) { - operation.Responses.Add("503", new OpenApiResponse() - { - Description = "The server is currently starting or is temporarily not available.", - Headers = new Dictionary<string, OpenApiHeader>() + operation.Responses.Add( + "503", + new OpenApiResponse { + Description = "The server is currently starting or is temporarily not available.", + Headers = new Dictionary<string, OpenApiHeader> { - "Retry-After", - new() { AllowEmptyValue = true, Required = false, Description = "A hint for when to retry the operation in full seconds." } + { + "Retry-After", new OpenApiHeader + { + AllowEmptyValue = true, + Required = false, + Description = "A hint for when to retry the operation in full seconds.", + Schema = new OpenApiSchema + { + Type = "integer", + Format = "int32" + } + } + }, + { + "Message", new OpenApiHeader + { + AllowEmptyValue = true, + Required = false, + Description = "A short plain-text reason why the server is not available.", + Schema = new OpenApiSchema + { + Type = "string", + Format = "text" + } + } + } }, + Content = new Dictionary<string, OpenApiMediaType>() { - "Message", - new() { AllowEmptyValue = true, Required = false, Description = "A short plain-text reason why the server is not available." } + { "text/html", new OpenApiMediaType() } } - }, - Content = new Dictionary<string, OpenApiMediaType>() - { - { - "text/html", - new OpenApiMediaType() - } - } - }); + }); } } diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index fe191916c6..188d3c4a9a 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -62,7 +62,7 @@ internal class JellyfinMigrationService #pragma warning disable CS0618 // Type or member is obsolete Migrations = [.. typeof(IMigrationRoutine).Assembly.GetTypes().Where(e => typeof(IMigrationRoutine).IsAssignableFrom(e) || typeof(IAsyncMigrationRoutine).IsAssignableFrom(e)) .Select(e => (Type: e, Metadata: e.GetCustomAttribute<JellyfinMigrationAttribute>(), Backup: e.GetCustomAttributes<JellyfinMigrationBackupAttribute>())) - .Where(e => e.Metadata != null) + .Where(e => e.Metadata is not null) .GroupBy(e => e.Metadata!.Stage) .Select(f => { @@ -137,7 +137,7 @@ internal class JellyfinMigrationService var migrationOptions = File.Exists(migrationConfigPath) ? (MigrationOptions)xmlSerializer.DeserializeFromFile(typeof(MigrationOptions), migrationConfigPath)! : null; - if (migrationOptions != null && migrationOptions.Applied.Count > 0) + if (migrationOptions is not null && migrationOptions.Applied.Count > 0) { logger.LogInformation("Old migration style migration.xml detected. Migrate now."); try @@ -383,7 +383,7 @@ internal class JellyfinMigrationService } } - if (backupInstruction.JellyfinDb && _jellyfinDatabaseProvider != null) + if (backupInstruction.JellyfinDb && _jellyfinDatabaseProvider is not null) { logger.LogInformation("A migration will attempt to modify the jellyfin.db, will attempt to backup the file now."); _backupKey = (_backupKey.LibraryDb, await _jellyfinDatabaseProvider.MigrationBackupFast(CancellationToken.None).ConfigureAwait(false), _backupKey.FullBackup); diff --git a/Jellyfin.Server/Migrations/Routines/FixDates.cs b/Jellyfin.Server/Migrations/Routines/FixDates.cs index f112502b9f..a5b11b11d0 100644 --- a/Jellyfin.Server/Migrations/Routines/FixDates.cs +++ b/Jellyfin.Server/Migrations/Routines/FixDates.cs @@ -41,14 +41,17 @@ public class FixDates : IAsyncMigrationRoutine { if (!TimeZoneInfo.Local.Equals(TimeZoneInfo.Utc)) { - using var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - var sw = Stopwatch.StartNew(); - - await FixBaseItemsAsync(context, sw, cancellationToken).ConfigureAwait(false); - sw.Reset(); - await FixChaptersAsync(context, sw, cancellationToken).ConfigureAwait(false); - sw.Reset(); - await FixBaseItemImageInfos(context, sw, cancellationToken).ConfigureAwait(false); + var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var sw = Stopwatch.StartNew(); + + await FixBaseItemsAsync(context, sw, cancellationToken).ConfigureAwait(false); + sw.Reset(); + await FixChaptersAsync(context, sw, cancellationToken).ConfigureAwait(false); + sw.Reset(); + await FixBaseItemImageInfos(context, sw, cancellationToken).ConfigureAwait(false); + } } } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs index e04a2737a6..b90da9f7d3 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs @@ -99,6 +99,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine var baseItemIds = new HashSet<Guid>(); using (var operation = GetPreparedDbContext("Moving TypedBaseItem")) { + IDictionary<Guid, (BaseItemEntity BaseItem, string[] Keys)> allItemsLookup = new Dictionary<Guid, (BaseItemEntity BaseItem, string[] Keys)>(); const string typedBaseItemsQuery = """ SELECT guid, type, data, StartDate, EndDate, ChannelId, IsMovie, @@ -115,12 +116,49 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine foreach (SqliteDataReader dto in connection.Query(typedBaseItemsQuery)) { var baseItem = GetItem(dto); - operation.JellyfinDbContext.BaseItems.Add(baseItem.BaseItem); - baseItemIds.Add(baseItem.BaseItem.Id); - foreach (var dataKey in baseItem.LegacyUserDataKey) + allItemsLookup.Add(baseItem.BaseItem.Id, baseItem); + } + } + + bool DoesResolve(Guid? parentId, HashSet<(BaseItemEntity BaseItem, string[] Keys)> checkStack) + { + if (parentId is null) + { + return true; + } + + if (!allItemsLookup.TryGetValue(parentId.Value, out var parent)) + { + return false; // item is detached and has no root anymore. + } + + if (!checkStack.Add(parent)) + { + return false; // recursive structure. Abort. + } + + return DoesResolve(parent.BaseItem.ParentId, checkStack); + } + + using (new TrackedMigrationStep("Clean TypedBaseItems hierarchy", _logger)) + { + var checkStack = new HashSet<(BaseItemEntity BaseItem, string[] Keys)>(); + + foreach (var item in allItemsLookup) + { + var cachedItem = item.Value; + if (DoesResolve(cachedItem.BaseItem.ParentId, checkStack)) { - legacyBaseItemWithUserKeys[dataKey] = baseItem.BaseItem; + checkStack.Add(cachedItem); + operation.JellyfinDbContext.BaseItems.Add(cachedItem.BaseItem); + baseItemIds.Add(cachedItem.BaseItem.Id); + foreach (var dataKey in cachedItem.Keys) + { + legacyBaseItemWithUserKeys[dataKey] = cachedItem.BaseItem; + } } + + checkStack.Clear(); } } @@ -128,6 +166,8 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine { operation.JellyfinDbContext.SaveChanges(); } + + allItemsLookup.Clear(); } using (var operation = GetPreparedDbContext("Moving ItemValues")) @@ -146,6 +186,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine foreach (SqliteDataReader dto in connection.Query(itemValueQuery)) { var itemId = dto.GetGuid(0); + if (!baseItemIds.Contains(itemId)) + { + continue; + } + var entity = GetItemValue(dto); var key = ((int)entity.Type, entity.Value); if (!localItems.TryGetValue(key, out var existing)) @@ -212,6 +257,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine continue; } + if (!baseItemIds.Contains(refItem.Id)) + { + continue; + } + userData.ItemId = refItem.Id; operation.JellyfinDbContext.UserData.Add(userData); } @@ -242,7 +292,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine { foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery)) { - operation.JellyfinDbContext.MediaStreamInfos.Add(GetMediaStream(dto)); + var entity = GetMediaStream(dto); + if (!baseItemIds.Contains(entity.ItemId)) + { + continue; + } + + operation.JellyfinDbContext.MediaStreamInfos.Add(entity); } } @@ -265,7 +321,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine { foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery)) { - operation.JellyfinDbContext.AttachmentStreamInfos.Add(GetMediaAttachment(dto)); + var entity = GetMediaAttachment(dto); + if (!baseItemIds.Contains(entity.ItemId)) + { + continue; + } + + operation.JellyfinDbContext.AttachmentStreamInfos.Add(entity); } } @@ -279,7 +341,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine { const string personsQuery = """ - SELECT ItemId, Name, Role, PersonType, SortOrder FROM People + SELECT ItemId, Name, Role, PersonType, SortOrder, ListOrder FROM People WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = People.ItemId) """; @@ -297,9 +359,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine } var entity = GetPerson(reader); - if (!peopleCache.TryGetValue(entity.Name, out var personCache)) + if (!peopleCache.TryGetValue(entity.Name + "|" + entity.PersonType, out var personCache)) { - peopleCache[entity.Name] = personCache = (entity, []); + peopleCache[entity.Name + "|" + entity.PersonType] = personCache = (entity, []); } if (reader.TryGetString(2, out var role)) @@ -307,6 +369,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine } int? sortOrder = reader.IsDBNull(4) ? null : reader.GetInt32(4); + int? listOrder = reader.IsDBNull(5) ? null : reader.GetInt32(5); personCache.Items.Add(new PeopleBaseItemMap() { @@ -314,7 +377,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine ItemId = itemId, People = null!, PeopleId = personCache.Person.Id, - ListOrder = sortOrder, + ListOrder = listOrder, SortOrder = sortOrder, Role = role }); @@ -350,6 +413,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine foreach (SqliteDataReader dto in connection.Query(chapterQuery)) { var chapter = GetChapter(dto); + if (!baseItemIds.Contains(chapter.ItemId)) + { + continue; + } + operation.JellyfinDbContext.Chapters.Add(chapter); } } @@ -376,6 +444,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery)) { var ancestorId = GetAncestorId(dto); + if (!baseItemIds.Contains(ancestorId.ItemId) || !baseItemIds.Contains(ancestorId.ParentItemId)) + { + continue; + } + operation.JellyfinDbContext.AncestorIds.Add(ancestorId); } } @@ -1086,12 +1159,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine if (reader.TryGetString(index++, out var providerIds)) { - entity.Provider = providerIds.Split('|').Select(e => e.Split("=")) + entity.Provider = providerIds.Split('|').Select(e => e.Split("=")).Where(e => e.Length >= 2) .Select(e => new BaseItemProvider() { Item = null!, ProviderId = e[0], - ProviderValue = e[1] + ProviderValue = string.Join('|', e.Skip(1)) }).ToArray(); } @@ -1189,7 +1262,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine ItemId = baseItemId, Id = Guid.NewGuid(), Path = e.Path, - Blurhash = e.BlurHash != null ? Encoding.UTF8.GetBytes(e.BlurHash) : null, + Blurhash = e.BlurHash is not null ? Encoding.UTF8.GetBytes(e.BlurHash) : null, DateModified = e.DateModified, Height = e.Height, Width = e.Width, diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 92e0129409..72626e8532 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -98,7 +98,7 @@ public sealed class SetupServer : IDisposable var maxLevel = logEntry.LogLevel; var stack = new Stack<StartupLogTopic>(children); - while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) != null) // error is the highest inherted error level. + while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level. { maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel; foreach (var child in logEntry.Children) diff --git a/Jellyfin.sln b/Jellyfin.sln index 21ef13e723..fb1f2a2c20 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -96,6 +96,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -258,6 +260,10 @@ Global {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.ActiveCfg = Release|Any CPU {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.Build.0 = Release|Any CPU + {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -289,6 +295,7 @@ Global {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} {8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} + {11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE} diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index bb0b26b8e2..4989f0f3f6 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -107,8 +107,15 @@ namespace MediaBrowser.Controller.Entities ProductionLocations = Array.Empty<string>(); RemoteTrailers = Array.Empty<MediaUrl>(); ExtraIds = Array.Empty<Guid>(); + UserData = []; } + /// <summary> + /// Gets or Sets the user data collection as cached from the last Db query. + /// </summary> + [JsonIgnore] + public ICollection<UserData> UserData { get; set; } + [JsonIgnore] public string PreferredMetadataCountryCode { get; set; } @@ -701,19 +708,7 @@ namespace MediaBrowser.Controller.Entities { get { - var customRating = CustomRating; - if (!string.IsNullOrEmpty(customRating)) - { - return customRating; - } - - var parent = DisplayParent; - if (parent is not null) - { - return parent.CustomRatingForComparison; - } - - return null; + return GetCustomRatingForComparision(); } } @@ -791,6 +786,26 @@ namespace MediaBrowser.Controller.Entities /// <value>The remote trailers.</value> public IReadOnlyList<MediaUrl> RemoteTrailers { get; set; } + private string GetCustomRatingForComparision(HashSet<Guid> callstack = null) + { + callstack ??= new(); + var customRating = CustomRating; + if (!string.IsNullOrEmpty(customRating)) + { + return customRating; + } + + callstack.Add(Id); + + var parent = DisplayParent; + if (parent is not null && !callstack.Contains(parent.Id)) + { + return parent.GetCustomRatingForComparision(callstack); + } + + return null; + } + public virtual double GetDefaultPrimaryImageAspectRatio() { return 0; @@ -2307,27 +2322,27 @@ namespace MediaBrowser.Controller.Entities return UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None); } - public virtual bool IsPlayed(User user) + public virtual bool IsPlayed(User user, UserItemData userItemData) { - var userdata = UserDataManager.GetUserData(user, this); + userItemData ??= UserDataManager.GetUserData(user, this); - return userdata is not null && userdata.Played; + return userItemData is not null && userItemData.Played; } - public bool IsFavoriteOrLiked(User user) + public bool IsFavoriteOrLiked(User user, UserItemData userItemData) { - var userdata = UserDataManager.GetUserData(user, this); + userItemData ??= UserDataManager.GetUserData(user, this); - return userdata is not null && (userdata.IsFavorite || (userdata.Likes ?? false)); + return userItemData is not null && (userItemData.IsFavorite || (userItemData.Likes ?? false)); } - public virtual bool IsUnplayed(User user) + public virtual bool IsUnplayed(User user, UserItemData userItemData) { ArgumentNullException.ThrowIfNull(user); - var userdata = UserDataManager.GetUserData(user, this); + userItemData ??= UserDataManager.GetUserData(user, this); - return userdata is null || !userdata.Played; + return userItemData is null || !userItemData.Played; } ItemLookupInfo IHasLookupInfo<ItemLookupInfo>.GetLookupInfo() diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 06cbcc2e18..e62004510f 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -42,6 +42,8 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class Folder : BaseItem { + private IEnumerable<BaseItem> _children; + public Folder() { LinkedChildren = Array.Empty<LinkedChild>(); @@ -108,11 +110,15 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// Gets the actual children. + /// Gets or Sets the actual children. /// </summary> /// <value>The actual children.</value> [JsonIgnore] - public virtual IEnumerable<BaseItem> Children => LoadChildren(); + public virtual IEnumerable<BaseItem> Children + { + get => _children ??= LoadChildren(); + set => _children = value; + } /// <summary> /// Gets thread-safe access to all recursive children of this folder - without regard to user. @@ -281,6 +287,7 @@ namespace MediaBrowser.Controller.Entities /// <returns>Task.</returns> public Task ValidateChildren(IProgress<double> progress, MetadataRefreshOptions metadataRefreshOptions, bool recursive = true, bool allowRemoveRoot = false, CancellationToken cancellationToken = default) { + Children = null; // invalidate cached children. return ValidateChildrenInternal(progress, recursive, true, allowRemoveRoot, metadataRefreshOptions, metadataRefreshOptions.DirectoryService, cancellationToken); } @@ -288,6 +295,7 @@ namespace MediaBrowser.Controller.Entities { var dictionary = new Dictionary<Guid, BaseItem>(); + Children = null; // invalidate cached children. var childrenList = Children.ToList(); foreach (var child in childrenList) @@ -329,6 +337,11 @@ namespace MediaBrowser.Controller.Entities try { + if (GetParents().Any(f => f.Id.Equals(Id))) + { + throw new InvalidOperationException("Recursive datastructure detected abort processing this item."); + } + await ValidateChildrenInternal2(progress, recursive, refreshChildMetadata, allowRemoveRoot, refreshOptions, directoryService, cancellationToken).ConfigureAwait(false); } finally @@ -526,6 +539,7 @@ namespace MediaBrowser.Controller.Entities { if (validChildrenNeedGeneration) { + Children = null; // invalidate cached children. validChildren = Children.ToList(); } @@ -568,7 +582,8 @@ namespace MediaBrowser.Controller.Entities if (recursive && child is Folder folder) { - await folder.RefreshMetadataRecursive(folder.Children.ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false); + folder.Children = null; // invalidate cached children. + await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false); } } } @@ -686,16 +701,22 @@ namespace MediaBrowser.Controller.Entities IEnumerable<BaseItem> items; Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager); + var totalCount = 0; if (query.User is null) { items = GetRecursiveChildren(filter); + totalCount = items.Count(); } else { - items = GetRecursiveChildren(user, query); + items = GetRecursiveChildren(user, query, out totalCount); + query.Limit = null; + query.StartIndex = null; // override these here as they have already been applied } - return PostFilterAndSort(items, query); + var result = PostFilterAndSort(items, query); + result.TotalRecordCount = totalCount; + return result; } if (this is not UserRootFolder @@ -944,22 +965,34 @@ namespace MediaBrowser.Controller.Entities IEnumerable<BaseItem> items; + int totalItemCount = 0; if (query.User is null) { items = Children.Where(filter); + totalItemCount = items.Count(); } else { // need to pass this param to the children. var childQuery = new InternalItemsQuery { - DisplayAlbumFolders = query.DisplayAlbumFolders + DisplayAlbumFolders = query.DisplayAlbumFolders, + Limit = query.Limit, + StartIndex = query.StartIndex, + NameStartsWith = query.NameStartsWith, + NameStartsWithOrGreater = query.NameStartsWithOrGreater, + NameLessThan = query.NameLessThan }; - items = GetChildren(user, true, childQuery).Where(filter); + items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter); + + query.Limit = null; + query.StartIndex = null; } - return PostFilterAndSort(items, query); + var result = PostFilterAndSort(items, query); + result.TotalRecordCount = totalItemCount; + return result; } protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query) @@ -1242,30 +1275,30 @@ namespace MediaBrowser.Controller.Entities return true; } - public IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren) - { - ArgumentNullException.ThrowIfNull(user); - - return GetChildren(user, includeLinkedChildren, new InternalItemsQuery(user)); - } - - public virtual IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) + public virtual IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, out int totalItemCount, InternalItemsQuery query = null) { ArgumentNullException.ThrowIfNull(user); + query ??= new InternalItemsQuery(); + query.User = user; // the true root should return our users root folder children if (IsPhysicalRoot) { - return LibraryManager.GetUserRootFolder().GetChildren(user, includeLinkedChildren); + return LibraryManager.GetUserRootFolder().GetChildren(user, includeLinkedChildren, out totalItemCount); } var result = new Dictionary<Guid, BaseItem>(); - AddChildren(user, includeLinkedChildren, result, false, query); + totalItemCount = AddChildren(user, includeLinkedChildren, result, false, query); return result.Values.ToArray(); } + public virtual IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query = null) + { + return GetChildren(user, includeLinkedChildren, out _, query); + } + protected virtual IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user) { return Children; @@ -1274,13 +1307,13 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Adds the children to list. /// </summary> - private void AddChildren(User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders = null) + private int AddChildren(User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders = null) { // Prevent infinite recursion of nested folders visitedFolders ??= new HashSet<Folder>(); if (!visitedFolders.Add(this)) { - return; + return 0; } // If Query.AlbumFolders is set, then enforce the format as per the db in that it permits sub-folders in music albums. @@ -1297,44 +1330,67 @@ namespace MediaBrowser.Controller.Entities children = GetEligibleChildrenForRecursiveChildren(user); } - AddChildrenFromCollection(children, user, includeLinkedChildren, result, recursive, query, visitedFolders); - if (includeLinkedChildren) { - AddChildrenFromCollection(GetLinkedChildren(user), user, includeLinkedChildren, result, recursive, query, visitedFolders); + children = children.Concat(GetLinkedChildren(user)).ToArray(); } + + return AddChildrenFromCollection(children, user, includeLinkedChildren, result, recursive, query, visitedFolders); } - private void AddChildrenFromCollection(IEnumerable<BaseItem> children, User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders) + private int AddChildrenFromCollection(IEnumerable<BaseItem> children, User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders) { - foreach (var child in children) + query ??= new InternalItemsQuery(); + var limit = query.Limit > 0 ? query.Limit : int.MaxValue; + query.Limit = 0; + + var visibleChildren = children + .Where(e => e.IsVisible(user)) + .ToArray(); + + var realChildren = visibleChildren + .Where(e => query is null || UserViewBuilder.FilterItem(e, query)) + .ToArray(); + + if (this is BoxSet && (query.OrderBy is null || query.OrderBy.Count == 0)) { - if (!child.IsVisible(user)) - { - continue; - } + realChildren = realChildren + .OrderBy(e => e.ProductionYear ?? int.MaxValue) + .ToArray(); + } - if (query is null || UserViewBuilder.FilterItem(child, query)) + var childCount = realChildren.Length; + if (result.Count < limit) + { + var remainingCount = (int)(limit - result.Count); + foreach (var child in realChildren + .Skip(query.StartIndex ?? 0) + .Take(remainingCount)) { result[child.Id] = child; } + } - if (recursive && child.IsFolder) + if (recursive) + { + foreach (var child in visibleChildren + .Where(e => e.IsFolder) + .OfType<Folder>()) { - var folder = (Folder)child; - - folder.AddChildren(user, includeLinkedChildren, result, true, query, visitedFolders); + childCount += child.AddChildren(user, includeLinkedChildren, result, true, query, visitedFolders); } } + + return childCount; } - public virtual IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) + public virtual IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount) { ArgumentNullException.ThrowIfNull(user); var result = new Dictionary<Guid, BaseItem>(); - AddChildren(user, true, result, true, query); + totalCount = AddChildren(user, true, result, true, query); return result.Values.ToArray(); } @@ -1666,23 +1722,14 @@ namespace MediaBrowser.Controller.Entities } } - public override bool IsPlayed(User user) + public override bool IsPlayed(User user, UserItemData userItemData) { - var itemsResult = GetItemList(new InternalItemsQuery(user) - { - Recursive = true, - IsFolder = false, - IsVirtualItem = false, - EnableTotalRecordCount = false - }); - - return itemsResult - .All(i => i.IsPlayed(user)); + return ItemRepository.GetIsPlayed(user, Id, true); } - public override bool IsUnplayed(User user) + public override bool IsUnplayed(User user, UserItemData userItemData) { - return !IsPlayed(user); + return !IsPlayed(user, userItemData); } public override void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields) diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index dd5852823e..1d1fb2c392 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -136,9 +136,9 @@ namespace MediaBrowser.Controller.Entities.Movies return Sort(children, user).ToArray(); } - public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) + public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount) { - var children = base.GetRecursiveChildren(user, query); + var children = base.GetRecursiveChildren(user, query, out totalCount); return Sort(children, user).ToArray(); } diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index 48211d99f2..b972ebaa6b 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -123,7 +123,7 @@ namespace MediaBrowser.Controller.Entities.TV public override int GetChildCount(User user) { - var result = GetChildren(user, true).Count; + var result = GetChildren(user, true, null).Count; return result; } diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 62c73d56f8..427c2995bc 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -297,6 +297,7 @@ namespace MediaBrowser.Controller.Entities.TV public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) { + Children = null; // invalidate cached children. // Refresh bottom up, seasons and episodes first, then the series var items = GetRecursiveChildren(); diff --git a/MediaBrowser.Controller/Entities/UserView.cs b/MediaBrowser.Controller/Entities/UserView.cs index dfa31315cb..5624f8b2e9 100644 --- a/MediaBrowser.Controller/Entities/UserView.cs +++ b/MediaBrowser.Controller/Entities/UserView.cs @@ -89,7 +89,7 @@ namespace MediaBrowser.Controller.Entities /// <inheritdoc /> public override int GetChildCount(User user) { - return GetChildren(user, true).Count; + return GetChildren(user, true, null).Count; } /// <inheritdoc /> @@ -134,20 +134,22 @@ namespace MediaBrowser.Controller.Entities } /// <inheritdoc /> - public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) + public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount) { query.SetUser(user); query.Recursive = true; query.EnableTotalRecordCount = false; query.ForceDirect = true; + var data = GetItemList(query); + totalCount = data.Count; - return GetItemList(query); + return data; } /// <inheritdoc /> protected override IReadOnlyList<BaseItem> GetEligibleChildrenForRecursiveChildren(User user) { - return GetChildren(user, false); + return GetChildren(user, false, null); } public static bool IsUserSpecific(Folder folder) diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 0cd3399d4a..4f9e9261b6 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -472,6 +472,23 @@ namespace MediaBrowser.Controller.Entities public static bool Filter(BaseItem item, User user, InternalItemsQuery query, IUserDataManager userDataManager, ILibraryManager libraryManager) { + if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase)) + { + return false; + } + +#pragma warning disable CA1309 // Use ordinal string comparison + if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater) && string.Compare(query.NameStartsWithOrGreater, item.SortName, StringComparison.InvariantCultureIgnoreCase) == 1) + { + return false; + } + + if (!string.IsNullOrEmpty(query.NameLessThan) && string.Compare(query.NameLessThan, item.SortName, StringComparison.InvariantCultureIgnoreCase) != 1) +#pragma warning restore CA1309 // Use ordinal string comparison + { + return false; + } + if (query.MediaTypes.Length > 0 && !query.MediaTypes.Contains(item.MediaType)) { return false; @@ -542,7 +559,7 @@ namespace MediaBrowser.Controller.Entities if (query.IsPlayed.HasValue) { userData ??= userDataManager.GetUserData(user, item); - if (userData.Played != query.IsPlayed.Value) + if (item.IsPlayed(user, userData) != query.IsPlayed.Value) { return false; } diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 04f47b729d..1043029c6e 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -152,16 +152,7 @@ namespace MediaBrowser.Controller.Entities { get { - if (!string.IsNullOrEmpty(PrimaryVersionId)) - { - var item = LibraryManager.GetItemById(PrimaryVersionId); - if (item is Video video) - { - return video.MediaSourceCount; - } - } - - return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1; + return GetMediaSourceCount(); } } @@ -259,6 +250,27 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override MediaType MediaType => MediaType.Video; + private int GetMediaSourceCount(HashSet<Guid> callstack = null) + { + callstack ??= new(); + if (!string.IsNullOrEmpty(PrimaryVersionId)) + { + var item = LibraryManager.GetItemById(PrimaryVersionId); + if (item is Video video) + { + if (callstack.Contains(video.Id)) + { + return video.LinkedAlternateVersions.Length + video.LocalAlternateVersions.Length + 1; + } + + callstack.Add(video.Id); + return video.GetMediaSourceCount(callstack); + } + } + + return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1; + } + public override List<string> GetUserDataKeys() { var list = base.GetUserDataKeys(); diff --git a/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs b/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs index 2742f21e36..b53210b0b1 100644 --- a/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs +++ b/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs @@ -167,12 +167,12 @@ public static class XmlReaderExtensions // Only split by comma if there is no pipe in the string // We have to be careful to not split names like Matthew, Jr. - var separator = !value.Contains('|', StringComparison.Ordinal) + ReadOnlySpan<char> separator = !value.Contains('|', StringComparison.Ordinal) && !value.Contains(';', StringComparison.Ordinal) - ? new[] { ',' } - : new[] { '|', ';' }; + ? stackalloc[] { ',' } + : stackalloc[] { '|', ';' }; - foreach (var part in value.Trim().Trim(separator).Split(separator)) + foreach (var part in value.AsSpan().Trim().Trim(separator).ToString().Split(separator)) { if (!string.IsNullOrWhiteSpace(part)) { diff --git a/MediaBrowser.Controller/IDisplayPreferencesManager.cs b/MediaBrowser.Controller/IDisplayPreferencesManager.cs index a97096eaee..7e235ed26c 100644 --- a/MediaBrowser.Controller/IDisplayPreferencesManager.cs +++ b/MediaBrowser.Controller/IDisplayPreferencesManager.cs @@ -60,8 +60,15 @@ namespace MediaBrowser.Controller void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences); /// <summary> - /// Saves changes made to the database. + /// Updates or Creates the display preferences. /// </summary> - void SaveChanges(); + /// <param name="displayPreferences">The entity to update or create.</param> + void UpdateDisplayPreferences(DisplayPreferences displayPreferences); + + /// <summary> + /// Updates or Creates the display preferences for the given item. + /// </summary> + /// <param name="itemDisplayPreferences">The entity to update or create.</param> + void UpdateItemDisplayPreferences(ItemDisplayPreferences itemDisplayPreferences); } } diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index b72d1d0b4c..fcc5ed672a 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -337,6 +337,13 @@ namespace MediaBrowser.Controller.Library void DeleteItem(BaseItem item, DeleteOptions options); /// <summary> + /// Deletes items that are not having any children like Actors. + /// </summary> + /// <param name="items">Items to delete.</param> + /// <remarks>In comparison to <see cref="DeleteItem(BaseItem, DeleteOptions, BaseItem, bool)"/> this method skips a lot of steps assuming there are no children to recusively delete nor does it define the special handling for channels and alike.</remarks> + public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items); + + /// <summary> /// Deletes the item. /// </summary> /// <param name="item">Item to delete.</param> @@ -624,6 +631,8 @@ namespace MediaBrowser.Controller.Library QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery query); + IReadOnlyDictionary<string, MusicArtist[]> GetArtists(IReadOnlyList<string> names); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query); QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery query); diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 8d6211051b..43680f5c01 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Globalization; using System.Linq; using Jellyfin.Data.Enums; @@ -22,8 +21,7 @@ namespace MediaBrowser.Controller.MediaEncoding // For now, a common base class until the API and MediaEncoding classes are unified public class EncodingJobInfo { - public int? OutputAudioBitrate; - public int? OutputAudioChannels; + private static readonly char[] _separators = ['|', ',']; private TranscodeReason? _transcodeReasons = null; @@ -36,6 +34,10 @@ namespace MediaBrowser.Controller.MediaEncoding SupportedSubtitleCodecs = Array.Empty<string>(); } + public int? OutputAudioBitrate { get; set; } + + public int? OutputAudioChannels { get; set; } + public TranscodeReason TranscodeReasons { get @@ -586,7 +588,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (!string.IsNullOrEmpty(BaseRequest.Profile)) { - return BaseRequest.Profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return BaseRequest.Profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } if (!string.IsNullOrEmpty(codec)) @@ -595,7 +597,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(profile)) { - return profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } } @@ -606,7 +608,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (!string.IsNullOrEmpty(BaseRequest.VideoRangeType)) { - return BaseRequest.VideoRangeType.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return BaseRequest.VideoRangeType.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } if (!string.IsNullOrEmpty(codec)) @@ -615,7 +617,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(rangetype)) { - return rangetype.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return rangetype.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } } @@ -626,7 +628,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (!string.IsNullOrEmpty(BaseRequest.CodecTag)) { - return BaseRequest.CodecTag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return BaseRequest.CodecTag.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } if (!string.IsNullOrEmpty(codec)) @@ -635,7 +637,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(codectag)) { - return codectag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return codectag.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } } diff --git a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs index 9bf27b3b2e..bdd75da2f5 100644 --- a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs @@ -53,5 +53,13 @@ namespace MediaBrowser.Controller.MediaEncoding /// <param name="cancellationToken">The cancellation token.</param> /// <returns>System.String.</returns> Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, CancellationToken cancellationToken); + + /// <summary> + /// Extracts all extractable subtitles (text and pgs). + /// </summary> + /// <param name="mediaSource">The mediaSource.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 4757bfa303..1e0d77fe51 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -81,6 +81,16 @@ namespace MediaBrowser.Controller.Net protected abstract Task<TReturnDataType> GetDataToSend(); /// <summary> + /// Gets the data to send for a specific connection. + /// </summary> + /// <param name="connection">The connection.</param> + /// <returns>Task{`1}.</returns> + protected virtual Task<TReturnDataType> GetDataToSendForConnection(IWebSocketConnection connection) + { + return GetDataToSend(); + } + + /// <summary> /// Processes the message. /// </summary> /// <param name="message">The message.</param> @@ -174,17 +184,11 @@ namespace MediaBrowser.Controller.Net continue; } - var data = await GetDataToSend().ConfigureAwait(false); - if (data is null) - { - continue; - } - IEnumerable<Task> GetTasks() { foreach (var tuple in tuples) { - yield return SendDataInternal(data, tuple); + yield return SendDataForConnectionAsync(tuple); } } @@ -198,12 +202,19 @@ namespace MediaBrowser.Controller.Net } } - private async Task SendDataInternal(TReturnDataType data, (IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State) tuple) + private async Task SendDataForConnectionAsync((IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State) tuple) { try { var (connection, cts, state) = tuple; var cancellationToken = cts.Token; + + var data = await GetDataToSendForConnection(connection).ConfigureAwait(false); + if (data is null) + { + return; + } + await connection.SendAsync( new OutboundWebSocketMessage<TReturnDataType> { MessageType = Type, Data = data }, cancellationToken).ConfigureAwait(false); diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index a0dabbac62..0026ab2b5f 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -7,7 +7,9 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; @@ -21,8 +23,8 @@ public interface IItemRepository /// <summary> /// Deletes the item. /// </summary> - /// <param name="id">The identifier.</param> - void DeleteItem(Guid id); + /// <param name="ids">The identifier to delete.</param> + void DeleteItem(params IReadOnlyList<Guid> ids); /// <summary> /// Saves the items. @@ -112,4 +114,20 @@ public interface IItemRepository /// <param name="id">The id to check.</param> /// <returns>True if the item exists, otherwise false.</returns> Task<bool> ItemExistsAsync(Guid id); + + /// <summary> + /// Gets a value indicating wherever all children of the requested Id has been played. + /// </summary> + /// <param name="user">The userdata to check against.</param> + /// <param name="id">The Top id to check.</param> + /// <param name="recursive">Whever the check should be done recursive. Warning expensive operation.</param> + /// <returns>A value indicating whever all children has been played.</returns> + bool GetIsPlayed(User user, Guid id, bool recursive); + + /// <summary> + /// Gets all artist matches from the db. + /// </summary> + /// <param name="artistNames">The names of the artists.</param> + /// <returns>A map of the artist name and the potential matches.</returns> + IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames); } diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index 1062399e3f..fc367b8293 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -149,9 +149,11 @@ namespace MediaBrowser.Controller.Playlists return []; } - public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) + public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount) { - return GetPlayableItems(user, query); + var items = GetPlayableItems(user, query); + totalCount = items.Count; + return items; } public IReadOnlyList<Tuple<LinkedChild, BaseItem>> GetManageableItems() diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index d71d46c00e..8350d1613b 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -511,7 +511,7 @@ namespace MediaBrowser.MediaEncoding.Encoder ? "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_chapters -show_format" : "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_format"; - if (_proberSupportsFirstVideoFrame) + if (!isAudio && _proberSupportsFirstVideoFrame) { args += " -show_frames -only_first_vframe"; } diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 3f94f54c3c..00a9ae797d 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -30,9 +30,11 @@ namespace MediaBrowser.MediaEncoding.Probing private const string ArtistReplaceValue = " | "; - private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' }; - private readonly string[] _webmVideoCodecs = { "av1", "vp8", "vp9" }; - private readonly string[] _webmAudioCodecs = { "opus", "vorbis" }; + private static readonly char[] _basicDelimiters = ['/', ';']; + private static readonly char[] _nameDelimiters = [.. _basicDelimiters, '|', '\\']; + private static readonly char[] _genreDelimiters = [.. _basicDelimiters, ',']; + private static readonly string[] _webmVideoCodecs = ["av1", "vp8", "vp9"]; + private static readonly string[] _webmAudioCodecs = ["opus", "vorbis"]; private readonly ILogger _logger; private readonly ILocalizationManager _localization; @@ -174,7 +176,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (tags.TryGetValue("artists", out var artists) && !string.IsNullOrWhiteSpace(artists)) { - info.Artists = SplitDistinctArtists(artists, new[] { '/', ';' }, false).ToArray(); + info.Artists = SplitDistinctArtists(artists, _basicDelimiters, false).ToArray(); } else { @@ -932,12 +934,10 @@ namespace MediaBrowser.MediaEncoding.Probing } var frameInfo = frameInfoList?.FirstOrDefault(i => i.StreamIndex == stream.Index); - if (frameInfo?.SideDataList != null) + if (frameInfo?.SideDataList is not null + && frameInfo.SideDataList.Any(data => string.Equals(data.SideDataType, "HDR Dynamic Metadata SMPTE2094-40 (HDR10+)", StringComparison.OrdinalIgnoreCase))) { - if (frameInfo.SideDataList.Any(data => string.Equals(data.SideDataType, "HDR Dynamic Metadata SMPTE2094-40 (HDR10+)", StringComparison.OrdinalIgnoreCase))) - { - stream.Hdr10PlusPresentFlag = true; - } + stream.Hdr10PlusPresentFlag = true; } } else if (streamInfo.CodecType == CodecType.Data) @@ -1554,7 +1554,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (tags.TryGetValue("WM/Genre", out var genres) && !string.IsNullOrWhiteSpace(genres)) { - var genreList = genres.Split(new[] { ';', '/', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var genreList = genres.Split(_genreDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); // If this is empty then don't overwrite genres that might have been fetched earlier if (genreList.Length > 0) @@ -1571,7 +1571,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (tags.TryGetValue("WM/MediaCredits", out var people) && !string.IsNullOrEmpty(people)) { video.People = Array.ConvertAll( - people.Split(new[] { ';', '/' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + people.Split(_basicDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), i => new BaseItemPerson { Name = i, Type = PersonKind.Actor }); } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 359927d4db..88a7bb4b41 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -169,7 +169,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles { if (fileInfo.IsExternal) { - using (var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false)) + var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) { var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false); var detected = result.Detected; @@ -476,13 +477,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase); } - /// <summary> - /// Extracts all extractable subtitles (text and pgs). - /// </summary> - /// <param name="mediaSource">The mediaSource.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - private async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken) + /// <inheritdoc /> + public async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken) { var locks = new List<IDisposable>(); var extractableStreams = new List<MediaStream>(); @@ -937,7 +933,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles .ConfigureAwait(false); } - using (var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false)) + var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) { var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false); var charset = result.Detected?.EncodingName ?? string.Empty; diff --git a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs index 1cb6bf234c..eccf8a606d 100644 --- a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs +++ b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs @@ -69,14 +69,8 @@ public class BoxSetMetadataService : MetadataService<BoxSet, BoxSetInfo> if (mergeMetadataSettings) { - if (replaceData || targetItem.LinkedChildren.Length == 0) - { - targetItem.LinkedChildren = sourceItem.LinkedChildren; - } - else - { - targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray(); - } + // TODO: Change to only replace when currently empty or requested. This is currently not done because the metadata service is not handling attaching collection items based on the provider responses + targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray(); } } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 0f2188aa8a..1d83263c5e 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -1279,7 +1279,7 @@ namespace MediaBrowser.Providers.Manager { if (source is Video sourceCast && target is Video targetCast) { - if (replaceData || !targetCast.Video3DFormat.HasValue) + if (sourceCast.Video3DFormat.HasValue && (replaceData || !targetCast.Video3DFormat.HasValue)) { targetCast.Video3DFormat = sourceCast.Video3DFormat; } diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index c0680b9019..587cb4092b 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -437,12 +437,12 @@ namespace MediaBrowser.Providers.MediaInfo { audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, recordingMbId); } - else if (TryGetSanitizedAdditionalFields(track, "UFID", out var ufIdValue) && !string.IsNullOrEmpty(ufIdValue)) + else if (TryGetSanitizedUFIDFields(track, out var owner, out var identifier) && !string.IsNullOrEmpty(owner) && !string.IsNullOrEmpty(identifier)) { // If tagged with MB Picard, the format is 'http://musicbrainz.org\0<recording MBID>' - if (ufIdValue.Contains("musicbrainz.org", StringComparison.OrdinalIgnoreCase)) + if (owner.Contains("musicbrainz.org", StringComparison.OrdinalIgnoreCase)) { - audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, ufIdValue.AsSpan().RightPart('\0').ToString()); + audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, identifier); } } } @@ -537,5 +537,24 @@ namespace MediaBrowser.Providers.MediaInfo value = GetSanitizedStringTag(value, track.Path); return hasField; } + + private bool TryGetSanitizedUFIDFields(Track track, out string? owner, out string? identifier) + { + var hasField = track.AdditionalFields.TryGetValue("UFID", out string? value); + if (hasField && !string.IsNullOrEmpty(value)) + { + string[] parts = value.Split('\0'); + if (parts.Length == 2) + { + owner = GetSanitizedStringTag(parts[0], track.Path); + identifier = GetSanitizedStringTag(parts[1], track.Path); + return true; + } + } + + owner = null; + identifier = null; + return false; + } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 2f8cb68ef5..ab072be03f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -213,15 +213,18 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var releases = movieResult.Releases.Countries.Where(i => !string.IsNullOrWhiteSpace(i.Certification)).ToList(); var ourRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, info.MetadataCountryCode, StringComparison.OrdinalIgnoreCase)); - var usRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase)); if (ourRelease is not null) { movie.OfficialRating = TmdbUtils.BuildParentalRating(ourRelease.Iso_3166_1, ourRelease.Certification); } - else if (usRelease is not null) + else { - movie.OfficialRating = usRelease.Certification; + var usRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase)); + if (usRelease is not null) + { + movie.OfficialRating = usRelease.Certification; + } } } @@ -340,9 +343,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (movieResult.Videos?.Results is not null) { var trailers = new List<MediaUrl>(); - for (var i = 0; i < movieResult.Videos.Results.Count; i++) + + var sortedVideos = movieResult.Videos.Results + .OrderByDescending(video => string.Equals(video.Type, "trailer", StringComparison.OrdinalIgnoreCase)); + + foreach (var video in sortedVideos) { - var video = movieResult.Videos.Results[i]; if (!TmdbUtils.IsTrailerType(video)) { continue; diff --git a/src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md b/src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000000..d23e3f9ed3 --- /dev/null +++ b/src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md @@ -0,0 +1,9 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +## Release 1.0 + +### New Rules +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +JF0001 | Usage | Warning | Async-created IAsyncDisposable objects should use 'await using' diff --git a/src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs b/src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs new file mode 100644 index 0000000000..90c8dfeca7 --- /dev/null +++ b/src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Jellyfin.CodeAnalysis; + +/// <summary> +/// Analyzer to detect sync disposal of async-created IAsyncDisposable objects. +/// </summary> +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class AsyncDisposalPatternAnalyzer : DiagnosticAnalyzer +{ + /// <summary> + /// Diagnostic descriptor for sync disposal of async-created IAsyncDisposable objects. + /// </summary> + public static readonly DiagnosticDescriptor AsyncDisposableSyncDisposal = new( + id: "JF0001", + title: "Async-created IAsyncDisposable objects should use 'await using'", + messageFormat: "Using 'using' with async-created IAsyncDisposable object '{0}'. Use 'await using' instead to prevent resource leaks.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Objects that implement IAsyncDisposable and are created using 'await' should be disposed using 'await using' to prevent resource leaks."); + + /// <inheritdoc/> + public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [AsyncDisposableSyncDisposal]; + + /// <inheritdoc/> + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeUsingStatement, SyntaxKind.UsingStatement); + } + + private static void AnalyzeUsingStatement(SyntaxNodeAnalysisContext context) + { + var usingStatement = (UsingStatementSyntax)context.Node; + + // Skip 'await using' statements + if (usingStatement.AwaitKeyword.IsKind(SyntaxKind.AwaitKeyword)) + { + return; + } + + // Check if there's a variable declaration + if (usingStatement.Declaration?.Variables is null) + { + return; + } + + foreach (var variable in usingStatement.Declaration.Variables) + { + if (variable.Initializer?.Value is AwaitExpressionSyntax awaitExpression) + { + var typeInfo = context.SemanticModel.GetTypeInfo(awaitExpression); + var type = typeInfo.Type; + + if (type is not null && ImplementsIAsyncDisposable(type)) + { + var diagnostic = Diagnostic.Create( + AsyncDisposableSyncDisposal, + usingStatement.GetLocation(), + type.Name); + + context.ReportDiagnostic(diagnostic); + } + } + } + } + + private static bool ImplementsIAsyncDisposable(ITypeSymbol type) + { + return type.AllInterfaces.Any(i => + string.Equals(i.Name, "IAsyncDisposable", StringComparison.Ordinal) + && string.Equals(i.ContainingNamespace?.ToDisplayString(), "System", StringComparison.Ordinal)); + } +} diff --git a/src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj b/src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj new file mode 100644 index 0000000000..64d20e9044 --- /dev/null +++ b/src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>netstandard2.0</TargetFramework> + <LangVersion>latest</LangVersion> + <IncludeBuildOutput>false</IncludeBuildOutput> + <GeneratePackageOnBuild>false</GeneratePackageOnBuild> + <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" /> + <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" /> + </ItemGroup> + +</Project> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs index a09a96317c..d58466e5ca 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs @@ -146,6 +146,8 @@ public class BaseItemEntity public Guid? ParentId { get; set; } + public BaseItemEntity? DirectParent { get; set; } + public Guid? TopParentId { get; set; } public Guid? SeasonId { get; set; } @@ -168,6 +170,8 @@ public class BaseItemEntity public ICollection<AncestorId>? Children { get; set; } + public ICollection<BaseItemEntity>? DirectChildren { get; set; } + public ICollection<BaseItemMetadataField>? LockedFields { get; set; } public ICollection<BaseItemTrailerType>? TrailerTypes { get; set; } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs index 9395b2e2dd..b90a2e056f 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs @@ -1,6 +1,7 @@ using System; using System.Data.Common; using System.Linq; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -28,15 +29,34 @@ public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior TimeSpan[] sleepDurations = [ TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(250), + TimeSpan.FromMilliseconds(250), + TimeSpan.FromMilliseconds(150), + TimeSpan.FromMilliseconds(150), TimeSpan.FromMilliseconds(150), TimeSpan.FromMilliseconds(500), + TimeSpan.FromMilliseconds(150), TimeSpan.FromMilliseconds(500), + TimeSpan.FromMilliseconds(150), TimeSpan.FromSeconds(3) ]; + + Func<int, Context, TimeSpan> backoffProvider = (index, context) => + { + var backoff = sleepDurations[index]; + return backoff + TimeSpan.FromMilliseconds(RandomNumberGenerator.GetInt32(0, (int)(backoff.TotalMilliseconds * .5))); + }; + _logger = logger; - _writePolicy = Policy.HandleInner<Exception>(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)).WaitAndRetry(sleepDurations, RetryHandle); - _writeAsyncPolicy = Policy.HandleInner<Exception>(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)).WaitAndRetryAsync(sleepDurations, RetryHandle); + _writePolicy = Policy + .HandleInner<Exception>(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)) + .WaitAndRetry(sleepDurations.Length, backoffProvider, RetryHandle); + _writeAsyncPolicy = Policy + .HandleInner<Exception>(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)) + .WaitAndRetryAsync(sleepDurations.Length, backoffProvider, RetryHandle); void RetryHandle(Exception exception, TimeSpan timespan, int retryNo, Context context) { diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs index bcf458abd5..6fccfd976d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs @@ -27,6 +27,7 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity> builder.HasMany(e => e.Provider); builder.HasMany(e => e.Parents); builder.HasMany(e => e.Children); + builder.HasMany(e => e.DirectChildren).WithOne(e => e.DirectParent).HasForeignKey(e => e.ParentId).OnDelete(DeleteBehavior.Cascade); builder.HasMany(e => e.LockedFields); builder.HasMany(e => e.TrailerTypes); builder.HasMany(e => e.Images); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs index 5e3ab44433..f7694aeda0 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs @@ -12,7 +12,7 @@ public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration<PeopleBas /// <inheritdoc/> public void Configure(EntityTypeBuilder<PeopleBaseItemMap> builder) { - builder.HasKey(e => new { e.ItemId, e.PeopleId }); + 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.HasOne(e => e.Item); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.Designer.cs new file mode 100644 index 0000000000..5c5464a46c --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.Designer.cs @@ -0,0 +1,1721 @@ +// <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("20250913211637_AddProperParentChildRelationBaseItemWithCascade")] + partial class AddProperParentChildRelationBaseItemWithCascade + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); + + 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<string>("ExtraIds") + .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>("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasColumnType("TEXT"); + + b.Property<string>("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<string>("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("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + 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 detacted 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"); + + 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", "ProviderValue", "ItemId"); + + 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.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<string>("KeyFrames") + .HasColumnType("TEXT"); + + b.Property<string>("Language") + .HasColumnType("TEXT"); + + b.Property<float?>("Level") + .HasColumnType("REAL"); + + b.Property<string>("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PixelFormat") + .HasColumnType("TEXT"); + + b.Property<string>("Profile") + .HasColumnType("TEXT"); + + b.Property<float?>("RealFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("RefFrames") + .HasColumnType("INTEGER"); + + b.Property<int?>("Rotation") + .HasColumnType("INTEGER"); + + b.Property<int?>("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("SampleRate") + .HasColumnType("INTEGER"); + + b.Property<int>("StreamType") + .HasColumnType("INTEGER"); + + b.Property<string>("TimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + 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<int?>("ListOrder") + .HasColumnType("INTEGER"); + + b.Property<string>("Role") + .HasColumnType("TEXT"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + 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("DeviceId"); + + 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>("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("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("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + 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", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + }); + + 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.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("Images"); + + b.Navigation("ItemValues"); + + 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/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs new file mode 100644 index 0000000000..38033d07f0 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class AddProperParentChildRelationBaseItemWithCascade : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" +DELETE FROM BaseItems + WHERE + ParentId IS NOT NULL + AND + NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); +DELETE FROM BaseItems + WHERE + ParentId IS NOT NULL + AND + NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); +DELETE FROM BaseItems + WHERE + ParentId IS NOT NULL + AND + NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); +DELETE FROM BaseItems + WHERE + ParentId IS NOT NULL + AND + NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); +"""); + migrationBuilder.AddForeignKey( + name: "FK_BaseItems_BaseItems_ParentId", + table: "BaseItems", + column: "ParentId", + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_BaseItems_BaseItems_ParentId", + table: "BaseItems"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.Designer.cs new file mode 100644 index 0000000000..edf30b0e77 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.Designer.cs @@ -0,0 +1,1721 @@ +// <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("20250925203415_ExtendPeopleMapKey")] + partial class ExtendPeopleMapKey + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); + + 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<string>("ExtraIds") + .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>("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasColumnType("TEXT"); + + b.Property<string>("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<string>("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("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + 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 detacted 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"); + + 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", "ProviderValue", "ItemId"); + + 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.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<string>("KeyFrames") + .HasColumnType("TEXT"); + + b.Property<string>("Language") + .HasColumnType("TEXT"); + + b.Property<float?>("Level") + .HasColumnType("REAL"); + + b.Property<string>("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PixelFormat") + .HasColumnType("TEXT"); + + b.Property<string>("Profile") + .HasColumnType("TEXT"); + + b.Property<float?>("RealFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("RefFrames") + .HasColumnType("INTEGER"); + + b.Property<int?>("Rotation") + .HasColumnType("INTEGER"); + + b.Property<int?>("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("SampleRate") + .HasColumnType("INTEGER"); + + b.Property<int>("StreamType") + .HasColumnType("INTEGER"); + + b.Property<string>("TimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + 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("DeviceId"); + + 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>("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("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("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + 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", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + }); + + 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.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("Images"); + + b.Navigation("ItemValues"); + + 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/20250925203415_ExtendPeopleMapKey.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.cs new file mode 100644 index 0000000000..7c1bcdf445 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class ExtendPeopleMapKey : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_PeopleBaseItemMap", + table: "PeopleBaseItemMap"); + + migrationBuilder.AlterColumn<string>( + name: "Role", + table: "PeopleBaseItemMap", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AddPrimaryKey( + name: "PK_PeopleBaseItemMap", + table: "PeopleBaseItemMap", + columns: new[] { "ItemId", "PeopleId", "Role" }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_PeopleBaseItemMap", + table: "PeopleBaseItemMap"); + + migrationBuilder.AlterColumn<string>( + name: "Role", + table: "PeopleBaseItemMap", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AddPrimaryKey( + name: "PK_PeopleBaseItemMap", + table: "PeopleBaseItemMap", + columns: new[] { "ItemId", "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 a7ff802afd..bea2364d74 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => { @@ -999,16 +999,16 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<Guid>("PeopleId") .HasColumnType("TEXT"); - b.Property<int?>("ListOrder") - .HasColumnType("INTEGER"); - b.Property<string>("Role") .HasColumnType("TEXT"); + b.Property<int?>("ListOrder") + .HasColumnType("INTEGER"); + b.Property<int?>("SortOrder") .HasColumnType("INTEGER"); - b.HasKey("ItemId", "PeopleId"); + b.HasKey("ItemId", "PeopleId", "Role"); b.HasIndex("PeopleId"); @@ -1450,6 +1450,16 @@ namespace Jellyfin.Server.Implementations.Migrations b.Navigation("Item"); }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => { b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") @@ -1652,6 +1662,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.Navigation("Children"); + b.Navigation("DirectChildren"); + b.Navigation("Images"); b.Navigation("ItemValues"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/PragmaConnectionInterceptor.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/PragmaConnectionInterceptor.cs new file mode 100644 index 0000000000..fd2b9bd05b --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/PragmaConnectionInterceptor.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Database.Providers.Sqlite; + +/// <summary> +/// Injects a series of PRAGMA on each connection starts. +/// </summary> +public class PragmaConnectionInterceptor : DbConnectionInterceptor +{ + private readonly ILogger _logger; + private readonly int? _cacheSize; + private readonly string _lockingMode; + private readonly int? _journalSizeLimit; + private readonly int _tempStoreMode; + private readonly int _syncMode; + private readonly IDictionary<string, string> _customPragma; + + /// <summary> + /// Initializes a new instance of the <see cref="PragmaConnectionInterceptor"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="cacheSize">Cache size.</param> + /// <param name="lockingMode">Locking mode.</param> + /// <param name="journalSizeLimit">Journal Size.</param> + /// <param name="tempStoreMode">The https://sqlite.org/pragma.html#pragma_temp_store pragma.</param> + /// <param name="syncMode">The https://sqlite.org/pragma.html#pragma_synchronous pragma.</param> + /// <param name="customPragma">A list of custom provided Pragma in the list of CustomOptions starting with "#PRAGMA:".</param> + public PragmaConnectionInterceptor(ILogger logger, int? cacheSize, string lockingMode, int? journalSizeLimit, int tempStoreMode, int syncMode, IDictionary<string, string> customPragma) + { + _logger = logger; + _cacheSize = cacheSize; + _lockingMode = lockingMode; + _journalSizeLimit = journalSizeLimit; + _tempStoreMode = tempStoreMode; + _syncMode = syncMode; + _customPragma = customPragma; + + InitialCommand = BuildCommandText(); + _logger.LogInformation("SQLITE connection pragma command set to: \r\n{PragmaCommand}", InitialCommand); + } + + private string? InitialCommand { get; set; } + + /// <inheritdoc/> + public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) + { + base.ConnectionOpened(connection, eventData); + + using (var command = connection.CreateCommand()) + { +#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities + command.CommandText = InitialCommand; +#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities + command.ExecuteNonQuery(); + } + } + + /// <inheritdoc/> + public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default) + { + await base.ConnectionOpenedAsync(connection, eventData, cancellationToken).ConfigureAwait(false); + + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { +#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities + command.CommandText = InitialCommand; +#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + } + + private string BuildCommandText() + { + var sb = new StringBuilder(); + if (_cacheSize.HasValue) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA cache_size={_cacheSize.Value};"); + } + + if (!string.IsNullOrWhiteSpace(_lockingMode)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA locking_mode={_lockingMode};"); + } + + if (_journalSizeLimit.HasValue) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA journal_size_limit={_journalSizeLimit};"); + } + + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA synchronous={_syncMode};"); + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA temp_store={_tempStoreMode};"); + + foreach (var item in _customPragma) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA {item.Key}={item.Value};"); + } + + return sb.ToString(); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index e52ab69d71..2b000b257b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -42,18 +42,56 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider /// <inheritdoc/> public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration) { + static T? GetOption<T>(ICollection<CustomDatabaseOption>? options, string key, Func<string, T> converter, Func<T>? defaultValue = null) + { + if (options is null) + { + return defaultValue is not null ? defaultValue() : default; + } + + var value = options.FirstOrDefault(e => e.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); + if (value is null) + { + return defaultValue is not null ? defaultValue() : default; + } + + return converter(value.Value); + } + + var customOptions = databaseConfiguration.CustomProviderOptions?.Options; + var sqliteConnectionBuilder = new SqliteConnectionStringBuilder(); sqliteConnectionBuilder.DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); - sqliteConnectionBuilder.Cache = Enum.Parse<SqliteCacheMode>(databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("cache", StringComparison.OrdinalIgnoreCase))?.Value ?? nameof(SqliteCacheMode.Default)); - sqliteConnectionBuilder.Pooling = (databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("pooling", StringComparison.OrdinalIgnoreCase))?.Value ?? bool.FalseString).Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase); + sqliteConnectionBuilder.Cache = GetOption(customOptions, "cache", Enum.Parse<SqliteCacheMode>, () => SqliteCacheMode.Default); + sqliteConnectionBuilder.Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true); + + var connectionString = sqliteConnectionBuilder.ToString(); + + // Log SQLite connection parameters + _logger.LogInformation("SQLite connection string: {ConnectionString}", connectionString); options .UseSqlite( - sqliteConnectionBuilder.ToString(), + connectionString, sqLiteOptions => sqLiteOptions.MigrationsAssembly(GetType().Assembly)) // TODO: Remove when https://github.com/dotnet/efcore/pull/35873 is merged & released .ConfigureWarnings(warnings => - warnings.Ignore(RelationalEventId.NonTransactionalMigrationOperationWarning)); + warnings.Ignore(RelationalEventId.NonTransactionalMigrationOperationWarning)) + .AddInterceptors(new PragmaConnectionInterceptor( + _logger, + GetOption<int?>(customOptions, "cacheSize", e => int.Parse(e, CultureInfo.InvariantCulture)), + GetOption(customOptions, "lockingmode", e => e, () => "NORMAL")!, + GetOption(customOptions, "journalsizelimit", int.Parse, () => 134_217_728), + GetOption(customOptions, "tempstoremode", int.Parse, () => 2), + GetOption(customOptions, "syncmode", int.Parse, () => 1), + customOptions?.Where(e => e.Key.StartsWith("#PRAGMA:", StringComparison.OrdinalIgnoreCase)).ToDictionary(e => e.Key["#PRAGMA:".Length..], e => e.Value) ?? [])); + + var enableSensitiveDataLogging = GetOption(customOptions, "EnableSensitiveDataLogging", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => false); + if (enableSensitiveDataLogging) + { + options.EnableSensitiveDataLogging(enableSensitiveDataLogging); + _logger.LogInformation("EnableSensitiveDataLogging is enabled on SQLite connection"); + } } /// <inheritdoc/> @@ -62,16 +100,11 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - if (context.Database.IsSqlite()) - { - await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); - _logger.LogInformation("jellyfin.db optimized successfully!"); - } - else - { - _logger.LogInformation("This database doesn't support optimization"); - } + await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); + await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); + await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); + await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); + _logger.LogInformation("jellyfin.db optimized successfully!"); } } diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs index e3afe15131..2270758454 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs @@ -200,8 +200,7 @@ namespace Jellyfin.LiveTv.TunerHosts var numberIndex = nameInExtInf.IndexOf(' '); if (numberIndex > 0) { - var numberPart = nameInExtInf.Slice(0, numberIndex).Trim(new[] { ' ', '.' }); - + var numberPart = nameInExtInf[..numberIndex].Trim(stackalloc[] { ' ', '.' }); if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _)) { numberString = numberPart.ToString(); @@ -273,12 +272,12 @@ namespace Jellyfin.LiveTv.TunerHosts var numberIndex = nameInExtInf.IndexOf(' ', StringComparison.Ordinal); if (numberIndex > 0) { - var numberPart = nameInExtInf.AsSpan(0, numberIndex).Trim(new[] { ' ', '.' }); + var numberPart = nameInExtInf.AsSpan(0, numberIndex).Trim(stackalloc[] { ' ', '.' }); if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _)) { // channel.Number = number.ToString(); - nameInExtInf = nameInExtInf.AsSpan(numberIndex + 1).Trim(new[] { ' ', '-' }).ToString(); + nameInExtInf = nameInExtInf.AsSpan(numberIndex + 1).Trim(stackalloc[] { ' ', '-' }).ToString(); } } } diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs index b5585f4fd2..cdebdadfbc 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs @@ -157,7 +157,17 @@ namespace Jellyfin.Providers.Tests.Manager Assert.True(TestMergeBaseItemData<Movie, MovieInfo>(propName, oldValue, newValue, null, true, out _)); Assert.True(TestMergeBaseItemData<Movie, MovieInfo>(propName, null, newValue, null, false, out _)); - Assert.True(TestMergeBaseItemData<Movie, MovieInfo>(propName, oldValue, null, null, true, out _)); + // Video3DFormat - null values do NOT replace existing data + if (string.Equals(propName, "Video3DFormat", StringComparison.Ordinal)) + { + Assert.False( + TestMergeBaseItemData<Movie, MovieInfo>(propName, oldValue, null, null, true, out _)); + } + else + { + Assert.True( + TestMergeBaseItemData<Movie, MovieInfo>(propName, oldValue, null, null, true, out _)); + } } [Fact] diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs index e7166d4246..36f1b726da 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs @@ -79,6 +79,8 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl using var createResponse = await client.PostAsJsonAsync("Library/VirtualFolders?name=test&refreshLibrary=true", createBody, _jsonOptions); Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode); + await Task.Delay(2000).ConfigureAwait(true); + using var response = await client.GetAsync("Library/VirtualFolders"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); |
