diff options
211 files changed, 8517 insertions, 1074 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f41dcff2a..7cf60e92f 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "9.0.5", + "version": "9.0.7", "commands": [ "dotnet-ef" ] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c2127ba5c..8b6b12c31 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,7 +13,7 @@ "dotnetRuntimeVersions": "9.0", "aspNetCoreRuntimeVersions": "9.0" }, - "ghcr.io/devcontainers-contrib/features/apt-packages:1": { + "ghcr.io/devcontainers-extra/features/apt-packages:1": { "preserve_apt_list": false, "packages": [ "libfontconfig1" diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 334a2edcf..f30192641 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@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 + uses: github/codeql-action/init@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 + uses: github/codeql-action/autobuild@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 + uses: github/codeql-action/analyze@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5 diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 048a1c3df..334c0f54a 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@c9576654e2fea2faa7b69e59550b3805bf6a9977 # v5.4.7 + uses: danielpalme/ReportGenerator-GitHub-Action@c1dd332d00304c5aa5d506aab698a5224a8fa24e # 5.4.11 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1ff5607ea..9849de057 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -27,6 +27,7 @@ - [cryptobank](https://github.com/cryptobank) - [cvium](https://github.com/cvium) - [dannymichel](https://github.com/dannymichel) + - [darioackermann](https://github.com/darioackermann) - [DaveChild](https://github.com/DaveChild) - [DavidFair](https://github.com/DavidFair) - [Delgan](https://github.com/Delgan) @@ -60,6 +61,7 @@ - [ikomhoog](https://github.com/ikomhoog) - [iwalton3](https://github.com/iwalton3) - [jftuga](https://github.com/jftuga) + - [jkhsjdhjs](https://github.com/jkhsjdhjs) - [jmshrv](https://github.com/jmshrv) - [joern-h](https://github.com/joern-h) - [joshuaboniface](https://github.com/joshuaboniface) @@ -195,7 +197,12 @@ - [Kenneth Cochran](https://github.com/kennethcochran) - [benedikt257](https://github.com/benedikt257) - [revam](https://github.com/revam) + - [Jxiced](https://github.com/Jxiced) - [allesmi](https://github.com/allesmi) + - [ThunderClapLP](https://github.com/ThunderClapLP) + - [Shoham Peller](https://github.com/spellr) + - [theshoeshiner](https://github.com/theshoeshiner) + - [TokerX](https://github.com/TokerX) # Emby Contributors diff --git a/Directory.Packages.props b/Directory.Packages.props index a0a09d745..32ef7ffc9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,12 +9,12 @@ <PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" /> <PackageVersion Include="AutoFixture" Version="4.18.1" /> <PackageVersion Include="BDInfo" Version="0.8.0" /> - <PackageVersion Include="BitFaster.Caching" Version="2.5.3" /> + <PackageVersion Include="BitFaster.Caching" Version="2.5.4" /> <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" /> <PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" /> <PackageVersion Include="CommandLineParser" Version="2.9.1" /> <PackageVersion Include="coverlet.collector" Version="6.0.4" /> - <PackageVersion Include="Diacritics" Version="3.3.29" /> + <PackageVersion Include="Diacritics" Version="4.0.17" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> <PackageVersion Include="FsCheck.Xunit" Version="3.3.0" /> @@ -24,44 +24,45 @@ <PackageVersion Include="Ignore" Version="0.2.1" /> <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" /> <PackageVersion Include="libse" Version="4.0.12" /> - <PackageVersion Include="LrcParser" Version="2025.228.1" /> + <PackageVersion Include="LrcParser" Version="2025.623.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" /> - <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.5" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.7" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.7" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" /> - <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.5" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.5" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.5" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.5" /> + <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.7" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.7" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.7" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.7" /> <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="0.12.0" /> + <PackageVersion Include="NEbml" Version="1.0.0.3" /> <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /> <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.5.2" /> + <PackageVersion Include="Polly" Version="8.6.2" /> <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" /> <PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" /> <PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" /> @@ -69,21 +70,22 @@ <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> <PackageVersion Include="SharpFuzz" Version="2.2.0" /> - <PackageVersion Include="SkiaSharp" Version="3.119.0" /> - <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.0" /> - <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.0" /> + <!-- 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.3" /> + <PackageVersion Include="Svg.Skia" Version="3.0.4" /> <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.1" /> - <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.5" /> - <PackageVersion Include="System.Text.Json" Version="9.0.5" /> - <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.5" /> + <PackageVersion Include="System.Linq.Async" Version="6.0.3" /> + <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.7" /> + <PackageVersion Include="System.Text.Json" Version="9.0.7" /> + <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.7" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" /> - <PackageVersion Include="z440.atl.core" Version="6.24.0" /> + <PackageVersion Include="z440.atl.core" Version="7.2.0" /> <PackageVersion Include="TMDbLib" Version="2.2.0" /> <PackageVersion Include="UTF.Unknown" Version="2.5.1" /> <PackageVersion Include="Xunit.Priority" Version="1.1.6" /> @@ -91,4 +93,4 @@ <PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" /> <PackageVersion Include="xunit" Version="2.9.3" /> </ItemGroup> -</Project>
\ No newline at end of file +</Project> diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index dbb848603..1c518f0cc 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -188,7 +188,8 @@ namespace Emby.Naming.Common "disk", "vol", "volume", - "part" + "part", + "act" }; ArtistSubfolders = new[] @@ -572,6 +573,18 @@ namespace Emby.Naming.Common MediaType.Video), new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Filename, + "sample", + MediaType.Video), + + new ExtraRule( + ExtraType.ThemeSong, + ExtraRuleType.Filename, + "theme", + MediaType.Audio), + + new ExtraRule( ExtraType.Trailer, ExtraRuleType.Suffix, "-trailer", @@ -592,13 +605,7 @@ namespace Emby.Naming.Common new ExtraRule( ExtraType.Trailer, ExtraRuleType.Suffix, - " trailer", - MediaType.Video), - - new ExtraRule( - ExtraType.Sample, - ExtraRuleType.Filename, - "sample", + "- trailer", MediaType.Video), new ExtraRule( @@ -622,16 +629,10 @@ namespace Emby.Naming.Common new ExtraRule( ExtraType.Sample, ExtraRuleType.Suffix, - " sample", + "- sample", MediaType.Video), new ExtraRule( - ExtraType.ThemeSong, - ExtraRuleType.Filename, - "theme", - MediaType.Audio), - - new ExtraRule( ExtraType.Scene, ExtraRuleType.Suffix, "-scene", diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 7a01b02f3..3461b3c0d 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs @@ -97,14 +97,18 @@ namespace Emby.Naming.ExternalFiles if (culture is not null && pathInfo.Language is null) { - pathInfo.Language = culture.ThreeLetterISOLanguageName; + pathInfo.Language = culture.Name.Contains('-', StringComparison.OrdinalIgnoreCase) + ? culture.Name + : culture.ThreeLetterISOLanguageName; extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase); } else if (culture is not null && pathInfo.Language == "hin") { // Hindi language code "hi" collides with a hearing impaired flag - use as Hindi only if no other language is set pathInfo.IsHearingImpaired = true; - pathInfo.Language = culture.ThreeLetterISOLanguageName; + pathInfo.Language = culture.Name.Contains('-', StringComparison.OrdinalIgnoreCase) + ? culture.Name + : culture.ThreeLetterISOLanguageName; extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase); } else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Equals(s, StringComparison.OrdinalIgnoreCase))) diff --git a/Emby.Naming/Video/ExtraRuleResolver.cs b/Emby.Naming/Video/ExtraRuleResolver.cs index 528906589..2e0caa612 100644 --- a/Emby.Naming/Video/ExtraRuleResolver.cs +++ b/Emby.Naming/Video/ExtraRuleResolver.cs @@ -22,67 +22,45 @@ namespace Emby.Naming.Video /// <returns>Returns <see cref="ExtraResult"/> object.</returns> public static ExtraResult GetExtraInfo(string path, NamingOptions namingOptions, string? libraryRoot = "") { - var result = new ExtraResult(); + ExtraResult result = new ExtraResult(); - for (var i = 0; i < namingOptions.VideoExtraRules.Length; i++) + bool isAudioFile = AudioFileParser.IsAudioFile(path, namingOptions); + bool isVideoFile = VideoResolver.IsVideoFile(path, namingOptions); + + ReadOnlySpan<char> pathSpan = path.AsSpan(); + ReadOnlySpan<char> fileName = Path.GetFileName(pathSpan); + ReadOnlySpan<char> fileNameWithoutExtension = Path.GetFileNameWithoutExtension(pathSpan); + // Trim the digits from the end of the filename so we can recognize things like -trailer2 + ReadOnlySpan<char> trimmedFileNameWithoutExtension = fileNameWithoutExtension.TrimEnd(_digits); + ReadOnlySpan<char> directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan)); + string fullDirectory = Path.GetDirectoryName(pathSpan).ToString(); + + foreach (ExtraRule rule in namingOptions.VideoExtraRules) { - var rule = namingOptions.VideoExtraRules[i]; - if ((rule.MediaType == MediaType.Audio && !AudioFileParser.IsAudioFile(path, namingOptions)) - || (rule.MediaType == MediaType.Video && !VideoResolver.IsVideoFile(path, namingOptions))) + if ((rule.MediaType == MediaType.Audio && !isAudioFile) + || (rule.MediaType == MediaType.Video && !isVideoFile)) { continue; } - var pathSpan = path.AsSpan(); - if (rule.RuleType == ExtraRuleType.Filename) + bool isMatch = rule.RuleType switch { - var filename = Path.GetFileNameWithoutExtension(pathSpan); + ExtraRuleType.Filename => fileNameWithoutExtension.Equals(rule.Token, StringComparison.OrdinalIgnoreCase), + ExtraRuleType.Suffix => trimmedFileNameWithoutExtension.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase), + ExtraRuleType.Regex => Regex.IsMatch(fileName, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled), + ExtraRuleType.DirectoryName => directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase) + && !string.Equals(fullDirectory, libraryRoot, StringComparison.OrdinalIgnoreCase), + _ => false, + }; - if (filename.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)) - { - result.ExtraType = rule.ExtraType; - result.Rule = rule; - } - } - else if (rule.RuleType == ExtraRuleType.Suffix) + if (!isMatch) { - // Trim the digits from the end of the filename so we can recognize things like -trailer2 - var filename = Path.GetFileNameWithoutExtension(pathSpan).TrimEnd(_digits); - - if (filename.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase)) - { - result.ExtraType = rule.ExtraType; - result.Rule = rule; - } - } - else if (rule.RuleType == ExtraRuleType.Regex) - { - var filename = Path.GetFileName(path.AsSpan()); - - var isMatch = Regex.IsMatch(filename, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled); - - if (isMatch) - { - result.ExtraType = rule.ExtraType; - result.Rule = rule; - } - } - else if (rule.RuleType == ExtraRuleType.DirectoryName) - { - var directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan)); - string fullDirectory = Path.GetDirectoryName(pathSpan).ToString(); - if (directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase) - && !string.Equals(fullDirectory, libraryRoot, StringComparison.OrdinalIgnoreCase)) - { - result.ExtraType = rule.ExtraType; - result.Rule = rule; - } + continue; } - if (result.ExtraType is not null) - { - return result; - } + result.ExtraType = rule.ExtraType; + result.Rule = rule; + return result; } return result; diff --git a/Emby.Photos/PhotoProvider.cs b/Emby.Photos/PhotoProvider.cs index ac6c41ca5..e24c067d6 100644 --- a/Emby.Photos/PhotoProvider.cs +++ b/Emby.Photos/PhotoProvider.cs @@ -49,7 +49,7 @@ public class PhotoProvider : ICustomMetadataProvider<Photo>, IForcedProvider, IH if (item.IsFileProtocol) { var file = directoryService.GetFile(item.Path); - return file is not null && file.LastWriteTimeUtc != item.DateModified; + return file is not null && item.HasChanged(file.LastWriteTimeUtc); } return false; @@ -108,7 +108,7 @@ public class PhotoProvider : ICustomMetadataProvider<Photo>, IForcedProvider, IH var dateTaken = image.ImageTag.DateTime; if (dateTaken.HasValue) { - item.DateCreated = dateTaken.Value; + item.DateCreated = dateTaken.Value.ToUniversalTime(); item.PremiereDate = dateTaken.Value; item.ProductionYear = dateTaken.Value.Year; } diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 565d0f0c8..cbb0f6c56 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -62,6 +62,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LibraryTaskScheduler; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.MediaEncoding; @@ -552,6 +553,7 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<ISessionManager, SessionManager>(); serviceCollection.AddSingleton<ICollectionManager, CollectionManager>(); + serviceCollection.AddSingleton<ILimitedConcurrencyLibraryScheduler, LimitedConcurrencyLibraryScheduler>(); serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>(); @@ -650,6 +652,7 @@ namespace Emby.Server.Implementations CollectionFolder.ApplicationHost = this; Folder.UserViewManager = Resolve<IUserViewManager>(); Folder.CollectionManager = Resolve<ICollectionManager>(); + Folder.LimitedConcurrencyLibraryScheduler = Resolve<ILimitedConcurrencyLibraryScheduler>(); Episode.MediaEncoder = Resolve<IMediaEncoder>(); UserView.TVSeriesManager = Resolve<ITVSeriesManager>(); Video.RecordingsManager = Resolve<IRecordingsManager>(); diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 9e0a6080d..cf886ae82 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1065,7 +1065,12 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.Trickplay)) { - dto.Trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult(); + var trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult(); + dto.Trickplay = trickplay.ToDictionary( + mediaStream => mediaStream.Key, + mediaStream => mediaStream.Value.ToDictionary( + width => width.Key, + width => new TrickplayInfoDto(width.Value))); } dto.ExtraType = video.ExtraType; diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index a720c86fb..373b0994a 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -57,7 +57,7 @@ namespace Emby.Server.Implementations.HttpServer RemoteEndPoint = remoteEndPoint; _jsonOptions = JsonDefaults.Options; - LastActivityDate = DateTime.Now; + LastActivityDate = DateTime.UtcNow; } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs index 8b2869149..4874eca8e 100644 --- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs @@ -43,13 +43,11 @@ namespace Emby.Server.Implementations.Images protected IImageProcessor ImageProcessor { get; set; } protected virtual IReadOnlyCollection<ImageType> SupportedImages { get; } - = new ImageType[] { ImageType.Primary }; + = [ImageType.Primary]; /// <inheritdoc /> public string Name => "Dynamic Image Provider"; - protected virtual int MaxImageAgeDays => 7; - public int Order => 0; protected virtual bool Supports(BaseItem item) => true; @@ -292,8 +290,14 @@ namespace Emby.Server.Implementations.Images protected virtual bool HasChangedByDate(BaseItem item, ItemImageInfo image) { - var age = DateTime.UtcNow - image.DateModified; - return age.TotalDays > MaxImageAgeDays; + var path = image.Path; + if (!string.IsNullOrEmpty(path)) + { + var modificationDate = FileSystem.GetLastWriteTimeUtc(path); + return image.DateModified != modificationDate; + } + + return false; } protected string CreateSingleImage(IEnumerable<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType) diff --git a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs index f29a0b3ad..f9538fbad 100644 --- a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs @@ -38,7 +38,8 @@ namespace Emby.Server.Implementations.Library } // Don't ignore top level folders - if (fileInfo.IsDirectory && parent is AggregateFolder) + if (fileInfo.IsDirectory + && (parent is AggregateFolder || (parent?.IsTopParent ?? false))) { return false; } @@ -48,35 +49,21 @@ namespace Emby.Server.Implementations.Library return true; } - var filename = fileInfo.Name; - - if (fileInfo.IsDirectory) + if (parent is null) { - if (parent is not null) - { - // Ignore extras for unsupported types - if (_namingOptions.AllExtrasTypesFolderNames.ContainsKey(filename) - && parent is not AggregateFolder - && parent is not UserRootFolder) - { - return true; - } - } + return false; } - else + + if (fileInfo.IsDirectory) { - if (parent is not null) - { - // Don't resolve theme songs - if (Path.GetFileNameWithoutExtension(filename.AsSpan()).Equals(BaseItem.ThemeSongFileName, StringComparison.Ordinal) - && AudioFileParser.IsAudioFile(filename, _namingOptions)) - { - return true; - } - } + // Ignore extras for unsupported types + return _namingOptions.AllExtrasTypesFolderNames.ContainsKey(fileInfo.Name) + && parent is not UserRootFolder; } - return false; + // Don't resolve theme songs + return Path.GetFileNameWithoutExtension(fileInfo.Name.AsSpan()).Equals(BaseItem.ThemeSongFileName, StringComparison.Ordinal) + && AudioFileParser.IsAudioFile(fileInfo.Name, _namingOptions); } } } diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs index b0ed1de8d..401ca73b8 100644 --- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs @@ -42,6 +42,19 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule /// <returns>True if the file should be ignored.</returns> public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent) { + if (fileInfo.IsDirectory) + { + var dirIgnoreFile = FindIgnoreFile(new DirectoryInfo(fileInfo.FullName)); + if (dirIgnoreFile is null) + { + return false; + } + + // ignore the directory only if the .ignore file is empty + // evaluate individual files otherwise + return string.IsNullOrWhiteSpace(GetFileContent(dirIgnoreFile)); + } + var parentDirPath = Path.GetDirectoryName(fileInfo.FullName); if (string.IsNullOrEmpty(parentDirPath)) { @@ -55,13 +68,9 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule return false; } - string ignoreFileString; - using (var reader = ignoreFile.OpenText()) - { - ignoreFileString = reader.ReadToEnd(); - } + string ignoreFileString = GetFileContent(ignoreFile); - if (string.IsNullOrEmpty(ignoreFileString)) + if (string.IsNullOrWhiteSpace(ignoreFileString)) { // Ignore directory if we just have the file return true; @@ -74,4 +83,12 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule return ignore.IsIgnored(fileInfo.FullName); } + + private static string GetFileContent(FileInfo dirIgnoreFile) + { + using (var reader = dirIgnoreFile.OpenText()) + { + return reader.ReadToEnd(); + } + } } diff --git a/Emby.Server.Implementations/Library/ExternalDataManager.cs b/Emby.Server.Implementations/Library/ExternalDataManager.cs index 68e3aaff4..d3cfa1d25 100644 --- a/Emby.Server.Implementations/Library/ExternalDataManager.cs +++ b/Emby.Server.Implementations/Library/ExternalDataManager.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Linq; using System.Threading; @@ -6,6 +7,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Trickplay; +using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library; @@ -18,6 +20,7 @@ public class ExternalDataManager : IExternalDataManager private readonly IMediaSegmentManager _mediaSegmentManager; private readonly IPathManager _pathManager; private readonly ITrickplayManager _trickplayManager; + private readonly ILogger<ExternalDataManager> _logger; /// <summary> /// Initializes a new instance of the <see cref="ExternalDataManager"/> class. @@ -26,16 +29,19 @@ public class ExternalDataManager : IExternalDataManager /// <param name="mediaSegmentManager">The media segment manager.</param> /// <param name="pathManager">The path manager.</param> /// <param name="trickplayManager">The trickplay manager.</param> + /// <param name="logger">The logger.</param> public ExternalDataManager( IKeyframeManager keyframeManager, IMediaSegmentManager mediaSegmentManager, IPathManager pathManager, - ITrickplayManager trickplayManager) + ITrickplayManager trickplayManager, + ILogger<ExternalDataManager> logger) { _keyframeManager = keyframeManager; _mediaSegmentManager = mediaSegmentManager; _pathManager = pathManager; _trickplayManager = trickplayManager; + _logger = logger; } /// <inheritdoc/> @@ -47,7 +53,14 @@ public class ExternalDataManager : IExternalDataManager { foreach (var path in validPaths) { - Directory.Delete(path, true); + try + { + Directory.Delete(path, true); + } + catch (Exception ex) + { + _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex); + } } } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index d03c614cf..df71868b6 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1954,7 +1954,7 @@ namespace Emby.Server.Implementations.Library try { - return _fileSystem.GetLastWriteTimeUtc(image.Path) != image.DateModified; + return image.DateModified.Subtract(_fileSystem.GetLastWriteTimeUtc(image.Path)).Duration().TotalSeconds > 1; } catch (Exception ex) { @@ -1981,6 +1981,8 @@ namespace Emby.Server.Implementations.Library return; } + var anyChange = false; + foreach (var img in outdated) { var image = img; @@ -2012,6 +2014,7 @@ namespace Emby.Server.Implementations.Library try { size = _imageProcessor.GetImageDimensions(item, image); + anyChange = image.Width != size.Width || image.Height != size.Height; image.Width = size.Width; image.Height = size.Height; } @@ -2019,23 +2022,29 @@ namespace Emby.Server.Implementations.Library { _logger.LogError(ex, "Cannot get image dimensions for {ImagePath}", image.Path); size = default; + anyChange = image.Width != size.Width || image.Height != size.Height; image.Width = 0; image.Height = 0; } try { - image.BlurHash = _imageProcessor.GetImageBlurHash(image.Path, size); + var blurhash = _imageProcessor.GetImageBlurHash(image.Path, size); + anyChange = anyChange || !blurhash.Equals(image.BlurHash, StringComparison.Ordinal); + image.BlurHash = blurhash; } catch (Exception ex) { _logger.LogError(ex, "Cannot compute blurhash for {ImagePath}", image.Path); + anyChange = anyChange || !string.IsNullOrEmpty(image.BlurHash); image.BlurHash = string.Empty; } try { - image.DateModified = _fileSystem.GetLastWriteTimeUtc(image.Path); + var modifiedDate = _fileSystem.GetLastWriteTimeUtc(image.Path); + anyChange = anyChange || modifiedDate != image.DateModified; + image.DateModified = modifiedDate; } catch (Exception ex) { @@ -2043,20 +2052,28 @@ namespace Emby.Server.Implementations.Library } } - _itemRepository.SaveImages(item); + if (anyChange) + { + _itemRepository.SaveImages(item); + } + RegisterItem(item); } /// <inheritdoc /> public async Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) { - _itemRepository.SaveItems(items, cancellationToken); - foreach (var item in items) { + item.DateLastSaved = DateTime.UtcNow; await RunMetadataSavers(item, updateReason).ConfigureAwait(false); + + // Modify again, so saved value is after write time of externally saved metadata + item.DateLastSaved = DateTime.UtcNow; } + _itemRepository.SaveItems(items, cancellationToken); + if (ItemUpdated is not null) { foreach (var item in items) @@ -2097,8 +2114,6 @@ namespace Emby.Server.Implementations.Library await ProviderManager.SaveMetadataAsync(item, updateReason).ConfigureAwait(false); } - item.DateLastSaved = DateTime.UtcNow; - await UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false); } @@ -2384,12 +2399,13 @@ namespace Emby.Server.Implementations.Library isNew = true; } - var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval; + var lastRefreshedUtc = item.DateLastRefreshed; + var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval; if (!refresh && !item.DisplayParentId.IsEmpty()) { var displayParent = GetItemById(item.DisplayParentId); - refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed; + refresh = displayParent is not null && displayParent.DateLastSaved > lastRefreshedUtc; } if (refresh) @@ -2447,12 +2463,13 @@ namespace Emby.Server.Implementations.Library isNew = true; } - var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval; + var lastRefreshedUtc = item.DateLastRefreshed; + var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval; if (!refresh && !item.DisplayParentId.IsEmpty()) { var displayParent = GetItemById(item.DisplayParentId); - refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed; + refresh = displayParent is not null && displayParent.DateLastSaved > lastRefreshedUtc; } if (refresh) @@ -2522,12 +2539,13 @@ namespace Emby.Server.Implementations.Library item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); } - var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval; + var lastRefreshedUtc = item.DateLastRefreshed; + var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval; if (!refresh && !item.DisplayParentId.IsEmpty()) { var displayParent = GetItemById(item.DisplayParentId); - refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed; + refresh = displayParent is not null && displayParent.DateLastSaved > lastRefreshedUtc; } if (refresh) @@ -2987,21 +3005,28 @@ namespace Emby.Server.Implementations.Library if (personEntity is null) { - var path = Person.GetPath(person.Name); - var info = Directory.CreateDirectory(path); - var lastWriteTime = info.LastWriteTimeUtc; - personEntity = new Person() + try { - Name = person.Name, - Id = GetItemByNameId<Person>(path), - DateCreated = info.CreationTimeUtc, - DateModified = lastWriteTime, - Path = path - }; - - personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey(); - saveEntity = true; - createEntity = true; + var path = Person.GetPath(person.Name); + var info = Directory.CreateDirectory(path); + personEntity = new Person() + { + Name = person.Name, + Id = GetItemByNameId<Person>(path), + DateCreated = info.CreationTimeUtc, + DateModified = info.LastWriteTimeUtc, + Path = path + }; + + personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey(); + saveEntity = true; + createEntity = true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create person {Name}", person.Name); + continue; + } } foreach (var id in person.ProviderIds) @@ -3035,6 +3060,8 @@ namespace Emby.Server.Implementations.Library } await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); + personEntity.DateLastSaved = DateTime.UtcNow; + CreateItems([personEntity], null, CancellationToken.None); } } diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index ab30971e2..1e3b8ea76 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -379,7 +379,7 @@ namespace Emby.Server.Implementations.Library var culture = _localizationManager.FindLanguageInfo(language); if (culture is not null) { - return culture.ThreeLetterISOLanguageNames; + return culture.Name.Contains('-', StringComparison.OrdinalIgnoreCase) ? [culture.Name] : culture.ThreeLetterISOLanguageNames; } return [language]; diff --git a/Emby.Server.Implementations/Library/ResolverHelper.cs b/Emby.Server.Implementations/Library/ResolverHelper.cs index ab6bc4907..06aa772bd 100644 --- a/Emby.Server.Implementations/Library/ResolverHelper.cs +++ b/Emby.Server.Implementations/Library/ResolverHelper.cs @@ -140,7 +140,7 @@ namespace Emby.Server.Implementations.Library if (fileCreationDate is not null) { var dateCreated = fileCreationDate; - if (dateCreated.Equals(DateTime.MinValue)) + if (dateCreated == DateTime.MinValue) { dateCreated = DateTime.UtcNow; } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index d78f8b991..b2ceee97d 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -462,7 +462,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies { var movie = (T)result.Items[0]; movie.IsInMixedFolder = false; - movie.Name = Path.GetFileName(movie.ContainingFolderPath); + if (collectionType == CollectionType.movies || collectionType is null) + { + movie.Name = Path.GetFileName(movie.ContainingFolderPath); + } + return movie; } } diff --git a/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs index 38631e0de..e62c638ed 100644 --- a/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs +++ b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs @@ -125,7 +125,6 @@ public class CollectionPostScanTask : ILibraryPostScanTask boxSet = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions { Name = collectionName, - IsLocked = true }).ConfigureAwait(false); await _collectionManager.AddToCollectionAsync(boxSet.Id, movieIds).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 0e1b9e0d8..a92148caf 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -136,5 +136,7 @@ "TaskExtractMediaSegments": "فحص مقاطع الوسائط", "TaskExtractMediaSegmentsDescription": "يستخرج مقاطع وسائط من إضافات MediaSegment المُفعّلة.", "TaskMoveTrickplayImages": "تغيير مكان صور المعاينة السريعة", - "TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة." + "TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة.", + "CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم", + "CleanupUserDataTaskDescription": "مسح جميع بيانات المستخدم (حالة المشاهدة، والحالة المفضلة وما إلى ذلك) من الوسائط التي لم تعد موجودة لمدة 90 يومًا على الأقل." } diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index d5da04fb9..dec491d08 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -135,5 +135,7 @@ "TaskDownloadMissingLyrics": "Спампаваць зніклыя тэксты песень", "TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песень", "TaskExtractMediaSegments": "Сканіраванне медыя-сегмента", - "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay" + "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay", + "CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка", + "CleanupUserDataTaskDescription": "Ачысьціць усе дадзеныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён." } diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index 72f575753..fd3666ef1 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -136,5 +136,7 @@ "TaskExtractMediaSegmentsDescription": "Изважда медиини сегменти от MediaSegment плъгини.", "TaskMoveTrickplayImages": "Мигриране на Локацията за Trickplay изображения", "TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.", - "TaskExtractMediaSegments": "Сканиране за сегменти" + "TaskExtractMediaSegments": "Сканиране за сегменти", + "CleanupUserDataTask": "Задача за почистване на потребителски данни", + "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни." } diff --git a/Emby.Server.Implementations/Localization/Core/bn.json b/Emby.Server.Implementations/Localization/Core/bn.json index 268a141ff..fad3715f2 100644 --- a/Emby.Server.Implementations/Localization/Core/bn.json +++ b/Emby.Server.Implementations/Localization/Core/bn.json @@ -6,29 +6,29 @@ "Channels": "চ্যানেলসমূহ", "CameraImageUploadedFrom": "{0} থেকে একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে", "Books": "পুস্তকসমূহ", - "AuthenticationSucceededWithUserName": "{0} অনুমোদন সফল", + "AuthenticationSucceededWithUserName": "{0} সফলভাবে অথেন্টিকেট করেছেন", "Artists": "শিল্পীগণ", "Application": "অ্যাপ্লিকেশন", "Albums": "অ্যালবামসমূহ", - "HeaderFavoriteEpisodes": "প্রিব পর্বগুলো", + "HeaderFavoriteEpisodes": "প্রিয় পর্বগুলো", "HeaderFavoriteArtists": "প্রিয় শিল্পীরা", "HeaderFavoriteAlbums": "প্রিয় এলবামগুলো", "HeaderContinueWatching": "দেখতে থাকুন", "HeaderAlbumArtists": "অ্যালবাম শিল্পীবৃন্দ", - "Genres": "শৈলীধারাসমূহ", + "Genres": "জনরা", "Folders": "ফোল্ডারসমূহ", "Favorites": "পছন্দসমূহ", "FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে", - "AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {0}", + "AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {1}", "VersionNumber": "সংস্করণ {0}", "ValueSpecialEpisodeName": "বিশেষ পর্ব - {0}", "ValueHasBeenAddedToLibrary": "আপনার লাইব্রেরিতে {0} যোগ করা হয়েছে", - "UserStoppedPlayingItemWithValues": "{2}তে {1} বাজানো শেষ করেছেন {0}", - "UserStartedPlayingItemWithValues": "{2}তে {1} বাজাচ্ছেন {0}", + "UserStoppedPlayingItemWithValues": "{2}তে {1} প্লে শেষ করেছেন {0}", + "UserStartedPlayingItemWithValues": "{2}তে {1} প্লে করেছেন {0}", "UserPolicyUpdatedWithName": "{0} এর জন্য ব্যবহার নীতি আপডেট করা হয়েছে", "UserPasswordChangedWithName": "ব্যবহারকারী {0} এর পাসওয়ার্ড পরিবর্তিত হয়েছে", - "UserOnlineFromDevice": "{0}, {1} থেকে অনলাইন", - "UserOfflineFromDevice": "{0} {1} থেকে বিযুক্ত হয়ে গেছে", + "UserOnlineFromDevice": "{0}, {1} থেকে অনলাইন আছে", + "UserOfflineFromDevice": "{0} {1} থেকে বিচ্ছিন্ন হয়ে গেছে", "UserLockedOutWithName": "ব্যবহারকারী {0} ঢুকতে পারছে না", "UserDownloadingItemWithValues": "{0}, {1} ডাউনলোড করছে", "UserDeletedWithName": "ব্যবহারকারী {0}কে বাদ দেয়া হয়েছে", @@ -36,8 +36,8 @@ "User": "ব্যবহারকারী", "TvShows": "টিভি শোগুলো", "System": "সিস্টেম", - "Sync": "সমলয় স্থাপন", - "SubtitleDownloadFailureFromForItem": "{2} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ", + "Sync": "সমন্বয় করুন", + "SubtitleDownloadFailureFromForItem": "{0} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ হয়েছে", "StartupEmbyServerIsLoading": "জেলিফিন সার্ভার লোড হচ্ছে। দয়া করে একটু পরে আবার চেষ্টা করুন।", "Songs": "সঙ্গীতসমূহ", "Shows": "টিভি পর্ব", @@ -46,18 +46,18 @@ "ScheduledTaskFailedWithName": "{0} ব্যর্থ", "ProviderValue": "প্রদানকারী: {0}", "PluginUpdatedWithName": "{0} আপডেট করা হয়েছে", - "PluginUninstalledWithName": "{0} বাদ দেয়া হয়েছে", - "PluginInstalledWithName": "{0} ইন্সটল করা হয়েছে", + "PluginUninstalledWithName": "{0} আনইন্সটল হয়েছে", + "PluginInstalledWithName": "{0} ইন্সটল হয়েছে", "Plugin": "প্লাগিন", "Playlists": "প্লে লিস্ট সমূহ", - "Photos": "চিত্রসমূহ", - "NotificationOptionVideoPlaybackStopped": "ভিডিও চলা বন্ধ", - "NotificationOptionVideoPlayback": "ভিডিও চলা শুরু হয়েছে", + "Photos": "ছবিসমূহ", + "NotificationOptionVideoPlaybackStopped": "ভিডিও বন্ধ হয়েছে", + "NotificationOptionVideoPlayback": "ভিডিও শুরু হয়েছে", "NotificationOptionUserLockedOut": "ব্যবহারকারী ঢুকতে পারছে না", "NotificationOptionTaskFailed": "পরিকল্পিত কাজটি ব্যর্থ", - "NotificationOptionServerRestartRequired": "সার্ভার রিস্টার্ট বাধ্যতামূলক", - "NotificationOptionPluginUpdateInstalled": "প্লাগিন আপডেট ইন্সটল করা হয়েছে", - "NotificationOptionPluginUninstalled": "প্লাগিন বাদ দেয়া হয়েছে", + "NotificationOptionServerRestartRequired": "সার্ভার রিস্টার্ট করা লাগবে", + "NotificationOptionPluginUpdateInstalled": "প্লাগিন আপডেট ইন্সটল হয়েছে", + "NotificationOptionPluginUninstalled": "প্লাগিন আনইনষ্টল হয়েছে", "NotificationOptionPluginInstalled": "প্লাগিন ইন্সটল করা হয়েছে", "NotificationOptionPluginError": "প্লাগিন ব্যর্থ", "NotificationOptionNewLibraryContent": "নতুন কন্টেন্ট যোগ করা হয়েছে", @@ -76,8 +76,8 @@ "Movies": "চলচ্চিত্রসমূহ", "MixedContent": "মিশ্র কন্টেন্ট", "MessageServerConfigurationUpdated": "সার্ভারের কনফিগারেশন আপডেট করা হয়েছে", - "HeaderRecordingGroups": "রেকর্ডিং দল", - "MessageNamedServerConfigurationUpdatedWithValue": "সার্ভারের {0} কনফিগারেসনের অংশ আপডেট করা হয়েছে", + "HeaderRecordingGroups": "রেকর্ডিং গ্রুপগুলো", + "MessageNamedServerConfigurationUpdatedWithValue": "সার্ভার কনফিগারেশন সেকশন {0} আপডেট করা হয়েছে", "MessageApplicationUpdatedTo": "জেলিফিন সার্ভার {0} তে আপডেট করা হয়েছে", "MessageApplicationUpdated": "জেলিফিন সার্ভার আপডেট করা হয়েছে", "Latest": "সর্বশেষ", @@ -85,51 +85,57 @@ "LabelIpAddressValue": "আইপি এড্রেস: {0}", "ItemRemovedWithName": "{0} লাইব্রেরি থেকে বাদ দেয়া হয়েছে", "ItemAddedWithName": "{0} লাইব্রেরিতে যোগ করা হয়েছে", - "Inherit": "থেকে পাওয়া", + "Inherit": "মূল থেকে গ্রহণ করুন", "HomeVideos": "হোম ভিডিও", "HeaderNextUp": "এরপরে আসছে", "HeaderLiveTV": "লাইভ টিভি", "HeaderFavoriteSongs": "প্রিয় গানগুলো", "HeaderFavoriteShows": "প্রিয় শোগুলো", - "TasksLibraryCategory": "গ্রন্থাগার", + "TasksLibraryCategory": "লাইব্রেরি", "TasksMaintenanceCategory": "রক্ষণাবেক্ষণ", "TaskRefreshLibrary": "স্ক্যান মিডিয়া লাইব্রেরি", - "TaskRefreshChapterImagesDescription": "অধ্যায়গুলিতে থাকা ভিডিওগুলির জন্য থাম্বনেইল তৈরি ।", - "TaskRefreshChapterImages": "অধ্যায়ের চিত্রগুলি বের করুন", - "TaskCleanCacheDescription": "সিস্টেমে আর প্রয়োজন নেই ক্যাশ, ফাইলগুলি মুছে ফেলুন।", + "TaskRefreshChapterImagesDescription": "যেসব ভিডিওতে চ্যাপ্টার রয়েছে, তাদের জন্য থাম্বনেইল তৈরি করবে।", + "TaskRefreshChapterImages": "চ্যাপ্টার ইমেজ বের করুন", + "TaskCleanCacheDescription": "সিস্টেমের অপ্রয়োজনীয় ক্যাশ ফাইলগুলো মুছে ফেলবে।", "TaskCleanCache": "ক্লিন ক্যাশ ডিরেক্টরি", "TasksChannelsCategory": "ইন্টারনেট চ্যানেল", - "TasksApplicationCategory": "আবেদন", + "TasksApplicationCategory": "অ্যাপ্লিকেশন", "TaskDownloadMissingSubtitlesDescription": "মেটাডেটা কনফিগারেশনের উপর ভিত্তি করে অনুপস্থিত সাবটাইটেলগুলির জন্য ইন্টারনেট অনুসন্ধান করে।", "TaskDownloadMissingSubtitles": "অনুপস্থিত সাবটাইটেলগুলি ডাউনলোড করুন", "TaskRefreshChannelsDescription": "ইন্টারনেট চ্যানেল তথ্য রিফ্রেশ করুন।", "TaskRefreshChannels": "চ্যানেল রিফ্রেশ করুন", - "TaskCleanTranscodeDescription": "এক দিনেরও বেশি পুরানো ট্রান্সকোড ফাইলগুলি মুছে ফেলুন।", + "TaskCleanTranscodeDescription": "এক দিনেরও বেশি পুরানো ট্রান্সকোড ফাইলগুলি মুছে ফেলবে।", "TaskCleanTranscode": "ট্রান্সকোড ডিরেক্টরি ক্লিন করুন", "TaskUpdatePluginsDescription": "স্বয়ংক্রিয়ভাবে আপডেট কনফিগার করা প্লাগইনগুলির জন্য আপডেট ডাউনলোড এবং ইনস্টল করুন।", - "TaskUpdatePlugins": "প্লাগইন আপডেট করুন", - "TaskRefreshPeopleDescription": "আপনার মিডিয়া লাইব্রেরিতে অভিনেতা এবং পরিচালকদের জন্য মেটাডাটা আপডেট করুন।", - "TaskRefreshPeople": "পিপল রিফ্রেশ করুন", - "TaskCleanLogsDescription": "{0} দিনের বেশী পুরানো লগ ফাইলগুলি মুছে ফেলুন।", - "TaskCleanLogs": "লগ ডিরেক্টরি ক্লিন করুন", - "TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করুন।", + "TaskUpdatePlugins": "আপডেট প্লাগইন", + "TaskRefreshPeopleDescription": "আপনার মিডিয়া লাইব্রেরিতে অভিনেতা এবং পরিচালকদের জন্য মেটাডাটা আপডেট করবে।", + "TaskRefreshPeople": "ব্যক্তিদের তথ্য রিফ্রেশ", + "TaskCleanLogsDescription": "{0} দিনের বেশী পুরানো লগ ফাইলগুলি মুছে ফেলবে।", + "TaskCleanLogs": "ক্লিন লগ ডিরেক্টরি", + "TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করবে।", "Undefined": "অসঙ্গায়িত", "Forced": "জোরকরে", - "TaskCleanActivityLogDescription": "নির্ধারিত সময়ের আগের কাজের হিসাব মুছে দিন খালি করুন.", - "TaskCleanActivityLog": "কাজের ফাইল খালি করুন", + "TaskCleanActivityLogDescription": "নির্ধারিত সময়ের আগের অ্যাক্টিভিটি লগ মুছে দিবে।", + "TaskCleanActivityLog": "অ্যাক্টিভিটি লগ মুছুন", "Default": "ডিফল্ট", - "HearingImpaired": "দুর্বল শ্রবণক্ষমতাধরদের জন্য", + "HearingImpaired": "শ্রবণ প্রতিবন্ধী", "TaskOptimizeDatabaseDescription": "তথ্যভাণ্ডার সুবিন্যস্ত করে ও অব্যবহৃত জায়গা ছেড়ে দেয়। লাইব্রেরী স্ক্যান অথবা যেকোনো তথ্যভাণ্ডার পরিবর্তনের পর এই প্রক্রিয়া চালালে তথ্যভাণ্ডারের তথ্য প্রদান দ্রুততর হতে পারে।", "External": "বাহ্যিক", "TaskOptimizeDatabase": "তথ্যভাণ্ডার সুবিন্যাস", "TaskKeyframeExtractor": "কি-ফ্রেম নিষ্কাশক", "TaskKeyframeExtractorDescription": "ভিডিয়ো থেকে কি-ফ্রেম নিষ্কাশনের মাধ্যমে অধিকতর সঠিক HLS প্লে লিস্ট তৈরী করে। এই প্রক্রিয়া দীর্ঘ সময় ধরে চলতে পারে।", - "TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি করুন", + "TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি", "TaskRefreshTrickplayImagesDescription": "সক্ষম লাইব্রেরিতে ভিডিওর জন্য ট্রিকপ্লে প্রিভিউ তৈরি করে।", "TaskDownloadMissingLyricsDescription": "গানের লিরিক্স ডাউনলোড করে", - "TaskCleanCollectionsAndPlaylists": "সংগ্রহ এবং প্লেলিস্ট পরিষ্কার করুন", - "TaskCleanCollectionsAndPlaylistsDescription": "সংগ্রহ এবং প্লেলিস্ট থেকে আইটেমগুলি সরিয়ে দেয় যা আর বিদ্যমান নেই।", + "TaskCleanCollectionsAndPlaylists": "কালেকশন এবং প্লেলিস্ট পরিষ্কার করুন", + "TaskCleanCollectionsAndPlaylistsDescription": "কালেকশন এবং প্লেলিস্ট থেকে আইটেমগুলি সরিয়ে দেয় যা আর বিদ্যমান নেই।", "TaskExtractMediaSegments": "মিডিয়া সেগমেন্ট স্ক্যান", - "TaskExtractMediaSegmentsDescription": "MediaSegment সক্ষম প্লাগইনগুলি থেকে মিডিয়া সেগমেন্টগুলি বের করে বা প্রাপ্ত করে।", - "TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন" + "TaskExtractMediaSegmentsDescription": "মিডিয়া সেগমেন্ট সক্রিয় প্লাগইনগুলি থেকে মিডিয়া সেগমেন্টগুলি বের করে বা প্রাপ্ত করে।", + "TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন", + "TaskMoveTrickplayImagesDescription": "লাইব্রেরির সেটিং অনুযায়ী বিদ্যমান ট্রিকপ্লে ফাইলগুলো সরিয়ে নেবে।", + "TaskAudioNormalizationDescription": "অডিও নর্মালাইজেশন তথ্যের জন্য ফাইল স্ক্যান করবে।", + "CleanupUserDataTaskDescription": "৯০ দিন বা তার বেশি সময় ধরে অনুপস্থিত মিডিয়া থেকে সকল ব্যবহারকারীর ডেটা (ওয়াচ স্টেট, ফেভারিট স্ট্যাটাস ইত্যাদি) মুছে ফেলবে।", + "TaskMoveTrickplayImages": "ট্রিকপ্লে ইমেজের অবস্থান পরিবর্তন", + "TaskAudioNormalization": "অডিও নর্মলাইজেশন", + "CleanupUserDataTask": "ব্যবহারকারীর ডেটা পরিষ্কারের কাজ" } diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 6cce0e019..596df6348 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -13,10 +13,10 @@ "DeviceOnlineWithName": "{0} està connectat", "FailedLoginAttemptWithUserName": "Intent de connexió fallit des de {0}", "Favorites": "Preferits", - "Folders": "Carpetes", + "Folders": "Directoris", "Genres": "Gèneres", "HeaderAlbumArtists": "Artistes de l'àlbum", - "HeaderContinueWatching": "Continua veient", + "HeaderContinueWatching": "Continueu mirant", "HeaderFavoriteAlbums": "Àlbums preferits", "HeaderFavoriteArtists": "Artistes preferits", "HeaderFavoriteEpisodes": "Episodis preferits", @@ -24,11 +24,11 @@ "HeaderFavoriteSongs": "Cançons preferides", "HeaderLiveTV": "TV en directe", "HeaderNextUp": "A continuació", - "HeaderRecordingGroups": "Grups Musicals", + "HeaderRecordingGroups": "Grups musicals", "HomeVideos": "Vídeos domèstics", "Inherit": "Heretat", - "ItemAddedWithName": "{0} s'ha afegit a la biblioteca", - "ItemRemovedWithName": "{0} s'ha eliminat de la biblioteca", + "ItemAddedWithName": "{0} s'ha afegit a la mediateca", + "ItemRemovedWithName": "{0} s'ha eliminat de la mediateca", "LabelIpAddressValue": "Adreça IP: {0}", "LabelRunningTimeValue": "Temps en marxa: {0}", "Latest": "Darrers", @@ -43,7 +43,7 @@ "NameInstallFailed": "{0} instal·lació fallida", "NameSeasonNumber": "Temporada {0}", "NameSeasonUnknown": "Temporada desconeguda", - "NewVersionIsAvailable": "Una nova versió del servidor de Jellyfin està disponible per a descarregar.", + "NewVersionIsAvailable": "Hi ha disponible una versió nova del servidor de Jellyfin per a la descàrrega.", "NotificationOptionApplicationUpdateAvailable": "Actualització de l'aplicatiu disponible", "NotificationOptionApplicationUpdateInstalled": "Actualització de l'aplicatiu instal·lada", "NotificationOptionAudioPlayback": "Reproducció d'àudio iniciada", @@ -64,7 +64,7 @@ "Playlists": "Llistes de reproducció", "Plugin": "Complement", "PluginInstalledWithName": "{0} ha estat instal·lat", - "PluginUninstalledWithName": "S'ha instalat {0}", + "PluginUninstalledWithName": "S'ha instal·lat {0}", "PluginUpdatedWithName": "S'ha actualitzat {0}", "ProviderValue": "Proveïdor: {0}", "ScheduledTaskFailedWithName": "{0} ha fallat", @@ -72,10 +72,10 @@ "ServerNameNeedsToBeRestarted": "S'ha de reiniciar {0}", "Shows": "Sèries", "Songs": "Cançons", - "StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu de nou en una estona.", + "StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu-ho de nou en una estona.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Els subtítols per a {1} no s'han pogut baixar de {0}", - "Sync": "Sincronitzar", + "Sync": "Sincronitza", "System": "Sistema", "TvShows": "Sèries de TV", "User": "Usuari", @@ -89,52 +89,54 @@ "UserPolicyUpdatedWithName": "La política d'usuari s'ha actualitzat per a {0}", "UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1} a {2}", "UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1} a {2}", - "ValueHasBeenAddedToLibrary": "S'ha afegit {0} a la teva biblioteca", + "ValueHasBeenAddedToLibrary": "S'ha afegit {0} a la mediateca", "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versió {0}", "TaskDownloadMissingSubtitlesDescription": "Cerca a internet els subtítols que faltin a partir de la configuració de metadades.", - "TaskDownloadMissingSubtitles": "Descarrega els subtítols que faltin", + "TaskDownloadMissingSubtitles": "Descàrrega dels subtítols que faltin", "TaskRefreshChannelsDescription": "Actualitza la informació dels canals per internet.", "TaskRefreshChannels": "Actualitza els canals", - "TaskCleanTranscodeDescription": "Elimina els arxius de transcodificacions que tinguin més d'un dia.", - "TaskCleanTranscode": "Neteja les transcodificacions", - "TaskUpdatePluginsDescription": "Actualitza els complements que estan configurats per a actualitzar-se automàticament.", - "TaskUpdatePlugins": "Actualitza els complements", - "TaskRefreshPeopleDescription": "Actualitza les metadades dels actors i directors de la teva biblioteca de mitjans.", - "TaskRefreshPeople": "Actualitza les persones", - "TaskCleanLogsDescription": "Esborra els logs que tinguin més de {0} dies.", - "TaskCleanLogs": "Neteja els registres", - "TaskRefreshLibraryDescription": "Escaneja la biblioteca de mitjans buscant fitxers nous i refresca les metadades.", - "TaskRefreshLibrary": "Escaneja la biblioteca de mitjans", - "TaskRefreshChapterImagesDescription": "Crea les miniatures dels vídeos que tinguin capítols.", - "TaskRefreshChapterImages": "Extreure les imatges dels capítols", - "TaskCleanCacheDescription": "Elimina la memòria cau no necessària per al servidor.", - "TaskCleanCache": "Elimina la memòria cau", + "TaskCleanTranscodeDescription": "Elimina els fitxers de transcodificacions que tinguin més d'un dia.", + "TaskCleanTranscode": "Neteja de les transcodificacions", + "TaskUpdatePluginsDescription": "Descarrega i instal·la els complements que estiguin configurats per a actualitzar-se automàticament.", + "TaskUpdatePlugins": "Actualització dels complements", + "TaskRefreshPeopleDescription": "Actualització de les metadades dels actors i directors de la mediateca.", + "TaskRefreshPeople": "Actualització de les persones", + "TaskCleanLogsDescription": "Esborra els registres que tinguin més de {0} dies.", + "TaskCleanLogs": "Neteja dels registres", + "TaskRefreshLibraryDescription": "Escaneja les mediateques, a la cerca de fitxers nous i refresca les metadades.", + "TaskRefreshLibrary": "Escaneig de les mediateques", + "TaskRefreshChapterImagesDescription": "Creació de les miniatures dels vídeos que tinguin capítols.", + "TaskRefreshChapterImages": "Extracció de les imatges dels capítols", + "TaskCleanCacheDescription": "Eliminació de la memòria cau no necessària per al servidor.", + "TaskCleanCache": "Eliminació de la memòria cau", "TasksChannelsCategory": "Canals per internet", "TasksApplicationCategory": "Aplicatiu", - "TasksLibraryCategory": "Biblioteca", + "TasksLibraryCategory": "Mediateca", "TasksMaintenanceCategory": "Manteniment", - "TaskCleanActivityLogDescription": "Eliminades les entrades del registre d'activitats més antigues que l'antiguitat configurada.", - "TaskCleanActivityLog": "Buidar el registre d'activitat", + "TaskCleanActivityLogDescription": "Eliminació de les entrades del registre d'activitats més antigues que l'antiguitat configurada.", + "TaskCleanActivityLog": "Buidatge del registre d'activitat", "Undefined": "Indefinit", "Forced": "Forçat", "Default": "Per defecte", - "TaskOptimizeDatabaseDescription": "Compacta la base de dades i trunca l'espai lliure. Executar aquesta tasca després d’escanejar la biblioteca o fer altres canvis que impliquin modificacions a la base de dades pot millorar el rendiment.", - "TaskOptimizeDatabase": "Optimitzar la base de dades", - "TaskKeyframeExtractorDescription": "Extreu fotogrames clau dels fitxers de vídeo per crear llistes de reproducció HLS més precises. Aquesta tasca pot durar molt de temps.", - "TaskKeyframeExtractor": "Extractor de fotogrames clau", + "TaskOptimizeDatabaseDescription": "Compacta la base de dades i trunca l'espai lliure. Executar aquesta tasca després d’escanejar la mediateca o fer d'altres canvis que impliquin modificacions a la base de dades pot millorar el rendiment.", + "TaskOptimizeDatabase": "Optimització de la base de dades", + "TaskKeyframeExtractorDescription": "Extracció de fotogrames clau dels fitxers de vídeo per a crear llistes de reproducció HLS més precises. Aquesta tasca pot allargar-se molt en el temps.", + "TaskKeyframeExtractor": "Extracció de fotogrames clau", "External": "Extern", "HearingImpaired": "Discapacitat auditiva", - "TaskRefreshTrickplayImages": "Generar miniatures de línia de temps", - "TaskRefreshTrickplayImagesDescription": "Crear miniatures de línia de temps per vídeos en les biblioteques habilitades.", + "TaskRefreshTrickplayImages": "Generació d'imatges de previsualització", + "TaskRefreshTrickplayImagesDescription": "Creació d'imatges de previsualització per a vídeos en les mediateques habilitades.", "TaskCleanCollectionsAndPlaylistsDescription": "Esborra elements de col·leccions i llistes de reproducció que ja no existeixen.", - "TaskCleanCollectionsAndPlaylists": "Neteja les col·leccions i llistes de reproducció", - "TaskAudioNormalization": "Estabilització d'Àudio", - "TaskAudioNormalizationDescription": "Escaneja arxius per dades d'estabilització d'àudio.", - "TaskDownloadMissingLyricsDescription": "Baixar les lletres de les cançons", - "TaskDownloadMissingLyrics": "Baixar les lletres que falten", + "TaskCleanCollectionsAndPlaylists": "Neteja de les col·leccions i llistes de reproducció", + "TaskAudioNormalization": "Estabilització de l'àudio", + "TaskAudioNormalizationDescription": "Escaneja els fitxer per a obtenir dades de normalització de l'àudio.", + "TaskDownloadMissingLyricsDescription": "Descàrrega de les lletres de les cançons", + "TaskDownloadMissingLyrics": "Descàrrega de les lletres que faltin", "TaskExtractMediaSegments": "Escaneig de segments multimèdia", "TaskExtractMediaSegmentsDescription": "Extreu o obté segments multimèdia usant els connectors MediaSegment activats.", - "TaskMoveTrickplayImages": "Migra la ubicació de la imatge de Trickplay", - "TaskMoveTrickplayImagesDescription": "Mou els fitxers trickplay existents segons la configuració de la biblioteca." + "TaskMoveTrickplayImages": "Migració de la ubicació de la imatge de previsualització", + "TaskMoveTrickplayImagesDescription": "Mou els fitxers existents d'imatges de previsualització segons la configuració de la mediateca.", + "CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.", + "CleanupUserDataTask": "Tasca de neteja de dades d'usuari" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index ba2e2700d..e14edcffa 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -136,5 +136,7 @@ "TaskExtractMediaSegments": "Skenování segmentů médií", "TaskExtractMediaSegmentsDescription": "Extrahuje či získá segmenty médií pomocí zásuvných modulů MediaSegment.", "TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay", - "TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny." + "TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny.", + "CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.", + "CleanupUserDataTask": "Pročistit uživatelská data" } diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index d43d4097f..bbee38ba5 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -136,5 +136,7 @@ "TaskExtractMediaSegments": "Scan for mediesegmenter", "TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder", "TaskMoveTrickplayImagesDescription": "Flyt eksisterende trickplay-billeder jævnfør biblioteksindstillinger.", - "TaskExtractMediaSegmentsDescription": "Udtrækker eller henter mediesegmenter fra plugins som understøtter MediaSegment." + "TaskExtractMediaSegmentsDescription": "Udtrækker eller henter mediesegmenter fra plugins som understøtter MediaSegment.", + "CleanupUserDataTask": "Brugerdata oprydningsopgave", + "CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage." } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index a411e27e1..664da8249 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -90,7 +90,7 @@ "UserStartedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} auf {2} gestartet", "UserStoppedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} auf {2} beendet", "ValueHasBeenAddedToLibrary": "{0} wurde deiner Bibliothek hinzugefügt", - "ValueSpecialEpisodeName": "Extra - {0}", + "ValueSpecialEpisodeName": "Extra – {0}", "VersionNumber": "Version {0}", "TaskDownloadMissingSubtitlesDescription": "Sucht im Internet basierend auf den Metadaten-Einstellungen nach fehlenden Untertiteln.", "TaskDownloadMissingSubtitles": "Fehlende Untertitel herunterladen", @@ -136,5 +136,7 @@ "TaskExtractMediaSegments": "Mediensegmente scannen", "TaskExtractMediaSegmentsDescription": "Extrahiert oder empfängt Mediensegmente von Plugins die Mediensegmente nutzen.", "TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren", - "TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben." + "TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben.", + "CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten", + "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Anschaustatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind." } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index ca52ffb14..720f550b3 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -136,5 +136,7 @@ "TaskExtractMediaSegments": "Media Segment Scan", "TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.", "TaskMoveTrickplayImages": "Migrate Trickplay Image Location", - "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings." + "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.", + "CleanupUserDataTask": "User data cleanup task", + "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days." } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 9702ab712..c09d5af96 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -135,5 +135,7 @@ "TaskExtractMediaSegments": "Media Segment Scan", "TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.", "TaskMoveTrickplayImages": "Migrate Trickplay Image Location", - "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings." + "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.", + "CleanupUserDataTask": "User data cleanup task", + "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favorite status etc) from media that is no longer present for at least 90 days." } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index 661333d29..1ec5eaa2a 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -136,5 +136,7 @@ "TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.", "TaskExtractMediaSegments": "Escaneo de segmentos de medios", "TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.", - "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay" + "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", + "CleanupUserDataTask": "Tarea de limpieza de datos del usuario", + "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días." } diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json index 4df4b90d3..c9a798cac 100644 --- a/Emby.Server.Implementations/Localization/Core/eu.json +++ b/Emby.Server.Implementations/Localization/Core/eu.json @@ -135,5 +135,7 @@ "TaskExtractMediaSegmentsDescription": "Media segmentuak atera edo lortzen ditu MediaSegment gaituta duten pluginetik.", "TaskMoveTrickplayImages": "Aldatu Trickplay irudien kokalekua", "TaskMoveTrickplayImagesDescription": "Lehendik dauden trickplay fitxategiak liburutegiaren ezarpenen arabera mugitzen dira.", - "TaskAudioNormalizationDescription": "Audio normalizazio datuak lortzeko fitxategiak eskaneatzen ditu." + "TaskAudioNormalizationDescription": "Audio normalizazio datuak lortzeko fitxategiak eskaneatzen ditu.", + "CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.", + "CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina" } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index c9f580cd5..0814e6223 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -135,5 +135,7 @@ "TaskDownloadMissingLyricsDescription": "Ladataan sanoituksia", "TaskExtractMediaSegmentsDescription": "Poimii tai hankkii mediasegmenttejä MediaSegment-yhteensopivista laajennuksista.", "TaskMoveTrickplayImages": "Siirrä Trickplay-kuvien sijainti", - "TaskMoveTrickplayImagesDescription": "Siirtää olemassa olevia trickplay-tiedostoja kirjaston asetusten mukaan." + "TaskMoveTrickplayImagesDescription": "Siirtää olemassa olevia trickplay-tiedostoja kirjaston asetusten mukaan.", + "CleanupUserDataTask": "Käyttäjätietojen puhdistustehtävä", + "CleanupUserDataTaskDescription": "Puhdistaa kaikki käyttäjätiedot (katselutila, suosikit ym.) medioista, joita ei ole ollut saatavilla yli 90 päivään." } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index a10912f01..6d079d2f5 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -136,5 +136,7 @@ "TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque.", "TaskDownloadMissingLyrics": "Télécharger les paroles des chansons manquantes", "TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay", - "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment." + "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.", + "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", + "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index c337d1932..8bf41c02a 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -136,5 +136,7 @@ "TaskExtractMediaSegments": "Analyse des segments de média", "TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay", "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.", - "TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque." + "TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque.", + "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", + "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur" } diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json index b8e787c20..8c0ae8922 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -135,5 +135,7 @@ "TaskUpdatePlugins": "Nuashonraigh Breiseáin", "TaskCleanTranscodeDescription": "Scriostar comhaid traschódaithe níos mó ná lá amháin d'aois.", "TaskCleanTranscode": "Eolaire Transcode Glan", - "TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh" + "TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh", + "CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora", + "CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad." } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index 55b309098..ff6f6d232 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -135,5 +135,6 @@ "TaskMoveTrickplayImages": "Migrar a localización da imaxe de Trickplay", "TaskMoveTrickplayImagesDescription": "Move os ficheiros de reprodución con trickplay existentes segundo a configuración da biblioteca.", "TaskRefreshTrickplayImages": "Xerar imaxes de Trickplay", - "TaskAudioNormalizationDescription": "Analiza ficheiros para obter datos de normalización de audio." + "TaskAudioNormalizationDescription": "Analiza ficheiros para obter datos de normalización de audio.", + "CleanupUserDataTask": "Tarefa de limpeza de datos do usuario" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index 1809c9d3f..90c921898 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -32,8 +32,8 @@ "LabelIpAddressValue": "Ip כתובת: {0}", "LabelRunningTimeValue": "משך צפייה: {0}", "Latest": "אחרון", - "MessageApplicationUpdated": "שרת ג'ליפין עודכן", - "MessageApplicationUpdatedTo": "שרת ג'ליפין עודכן לגרסה {0}", + "MessageApplicationUpdated": "שרת Jellyfin עודכן", + "MessageApplicationUpdatedTo": "שרת Jellyfin עודכן לגרסה {0}", "MessageNamedServerConfigurationUpdatedWithValue": "סעיף הגדרת השרת {0} עודכן", "MessageServerConfigurationUpdated": "תצורת השרת עודכנה", "MixedContent": "תוכן מעורב", @@ -43,7 +43,7 @@ "NameInstallFailed": "התקנת {0} נכשלה", "NameSeasonNumber": "עונה {0}", "NameSeasonUnknown": "עונה לא ידועה", - "NewVersionIsAvailable": "גרסה חדשה של שרת ג'ליפין זמינה להורדה.", + "NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.", "NotificationOptionApplicationUpdateAvailable": "קיים עדכון זמין ליישום", "NotificationOptionApplicationUpdateInstalled": "עדכון ליישום הותקן", "NotificationOptionAudioPlayback": "ניגון שמע החל", @@ -72,7 +72,7 @@ "ServerNameNeedsToBeRestarted": "{0} דורש הפעלה מחדש", "Shows": "סדרות", "Songs": "שירים", - "StartupEmbyServerIsLoading": "שרת ג'ליפין טוען. נא לנסות שוב בקרוב.", + "StartupEmbyServerIsLoading": "שרת Jellyfin בתהליך טעינה. נא לנסות שוב בקרוב.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה", "Sync": "סנכרון", @@ -100,14 +100,14 @@ "TasksLibraryCategory": "ספרייה", "TasksMaintenanceCategory": "תחזוקה", "TaskUpdatePlugins": "עדכן תוספים", - "TaskRefreshPeopleDescription": "מעדכן מטא נתונים עבור שחקנים ובמאים בספריית המדיה שלך.", + "TaskRefreshPeopleDescription": "מעדכן מטא-דאטה עבור שחקנים ובמאים בספריית המדיה שלך.", "TaskRefreshPeople": "רענן אנשים", "TaskCleanLogsDescription": "מוחק קבצי יומן בני יותר מ- {0} ימים.", "TaskCleanLogs": "ניקוי תיקיית יומן", - "TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך אחר קבצים חדשים ומרענן מטא נתונים.", + "TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך אחר קבצים חדשים ומרענן מטא-דאטה.", "TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות לסרטונים שיש להם פרקים.", "TasksChannelsCategory": "ערוצי אינטרנט", - "TaskDownloadMissingSubtitlesDescription": "חפש באינטרנט עבור הכתוביות החסרות בהתבסס על המטה-דיאטה.", + "TaskDownloadMissingSubtitlesDescription": "חפש באינטרנט כתוביות חסרות בהתבסס על המטא-דאטה.", "TaskDownloadMissingSubtitles": "הורד כתוביות חסרות", "TaskRefreshChannelsDescription": "רענן פרטי ערוץ אינטרנטי.", "TaskRefreshChannels": "רענן ערוץ", @@ -136,5 +136,7 @@ "TaskMoveTrickplayImages": "העברת מיקום של תמונות Trickplay", "TaskExtractMediaSegments": "סריקת מדיה", "TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.", - "TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה." + "TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.", + "CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.", + "CleanupUserDataTask": "משימת ניקוי מידע משתמש" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 1a9c3ee8b..81a996330 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -1,6 +1,6 @@ { "Albums": "Albumok", - "AppDeviceValues": "Program: {0}, eszköz: {1}", + "AppDeviceValues": "Program: {0}, Eszköz: {1}", "Application": "Alkalmazás", "Artists": "Előadók", "AuthenticationSucceededWithUserName": "{0} sikeresen hitelesítve", @@ -136,5 +136,7 @@ "TaskDownloadMissingLyricsDescription": "Zenék szövegének letöltése", "TaskMoveTrickplayImages": "Trickplay képek helyének átköltöztetése", "TaskMoveTrickplayImagesDescription": "A médiatár-beállításoknak megfelelően áthelyezi a meglévő trickplay fájlokat.", - "TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből." + "TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből.", + "CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.", + "CleanupUserDataTask": "Felhasználói adatok tisztítása feladat" } diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index b925a482b..2a4281685 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -129,5 +129,13 @@ "TaskAudioNormalizationDescription": "Pindai file untuk data normalisasi audio.", "TaskAudioNormalization": "Normalisasi Audio", "TaskCleanCollectionsAndPlaylists": "Bersihkan koleksi dan daftar putar", - "TaskCleanCollectionsAndPlaylistsDescription": "Menghapus item dari koleksi dan daftar putar yang sudah tidak ada." + "TaskCleanCollectionsAndPlaylistsDescription": "Menghapus item dari koleksi dan daftar putar yang sudah tidak ada.", + "TaskDownloadMissingLyricsDescription": "Unduh lirik untuk lagu", + "TaskExtractMediaSegmentsDescription": "Mengekstrak atau memperoleh segmen media dari plugin yang mendukung MediaSegment.", + "TaskMoveTrickplayImagesDescription": "Memindahkan file trickplay yang sudah ada sesuai dengan pengaturan pustaka.", + "CleanupUserDataTaskDescription": "Membersihkan semua data pengguna (status tontonan, status favorit, dll.) dari media yang sudah tidak ada selama setidaknya 90 hari.", + "TaskExtractMediaSegments": "Scan Segmen media", + "TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay", + "TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang", + "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna" } diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json index 672c686fa..6f94df9d7 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -131,5 +131,8 @@ "TaskCleanCollectionsAndPlaylists": "Hreinsa söfn og spilunarlista", "TaskCleanCollectionsAndPlaylistsDescription": "Fjarlægir hluti úr söfnum og spilalistum sem eru ekki lengur til.", "TaskDownloadMissingLyricsDescription": "Sækja söngtexta fyrir lög", - "TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar" + "TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar", + "TaskExtractMediaSegments": "Skönnun efnishluta", + "CleanupUserDataTask": "Hreinsun notendagagna", + "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga." } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index e05afbabe..421c4ee30 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -136,5 +136,7 @@ "TaskMoveTrickplayImages": "Sposta le immagini Trickplay", "TaskMoveTrickplayImagesDescription": "Sposta le immagini Trickplay esistenti secondo la configurazione della libreria.", "TaskExtractMediaSegmentsDescription": "Estrae o ottiene segmenti multimediali dai plugin abilitati MediaSegment.", - "TaskExtractMediaSegments": "Scansiona Segmento Media" + "TaskExtractMediaSegments": "Scansiona Segmento Media", + "CleanupUserDataTask": "Task di pulizia dei dati utente", + "CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni." } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index 14a576592..d564d54ce 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -135,5 +135,7 @@ "TaskMoveTrickplayImages": "Trickplayの画像を移動", "TaskMoveTrickplayImagesDescription": "ライブラリ設定によりTrickplayのファイルを移動。", "TaskDownloadMissingLyrics": "失われた歌詞をダウンロード", - "TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。" + "TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。", + "CleanupUserDataTask": "ユーザーデータのクリーンアップタスク", + "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。" } diff --git a/Emby.Server.Implementations/Localization/Core/kn.json b/Emby.Server.Implementations/Localization/Core/kn.json index 5e2b3756b..9f49be53b 100644 --- a/Emby.Server.Implementations/Localization/Core/kn.json +++ b/Emby.Server.Implementations/Localization/Core/kn.json @@ -25,7 +25,7 @@ "DeviceOfflineWithName": "{0} ಸಂಪರ್ಕ ಕಡಿತಗೊಂಡಿದೆ", "DeviceOnlineWithName": "{0} ಸಂಪರ್ಕಗೊಂಡಿದೆ", "External": "ಹೊರಗಿನ", - "FailedLoginAttemptWithUserName": "{0} ರಿಂದ ವಿಫಲ ಲಾಗಿನ್ ಪ್ರಯತ್ನ", + "FailedLoginAttemptWithUserName": "ವಿಫಲ ಲಾಗಿನ್ ಪ್ರಯತ್ನ ಸಂಖ್ಯೆ {0}", "Favorites": "ಮೆಚ್ಚಿನವುಗಳು", "Folders": "ಫೋಲ್ಡರ್ಗಳು", "Forced": "ಬಲವಂತವಾಗಿ", @@ -123,5 +123,13 @@ "TaskUpdatePlugins": "ಪ್ಲಗಿನ್ಗಳನ್ನು ನವೀಕರಿಸಿ", "TaskCleanTranscode": "ಟ್ರಾನ್ಸ್ಕೋಡ್ ಡೈರೆಕ್ಟರಿಯನ್ನು ಸ್ವಚ್ಛಗೊಳಿಸಿ", "TaskRefreshChannels": "ಚಾನಲ್ಗಳನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡಿ", - "TaskRefreshChannelsDescription": "ಇಂಟರ್ನೆಟ್ ಚಾನಲ್ ಮಾಹಿತಿಯನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡುತ್ತದೆ." + "TaskRefreshChannelsDescription": "ಇಂಟರ್ನೆಟ್ ಚಾನಲ್ ಮಾಹಿತಿಯನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡುತ್ತದೆ.", + "TaskAudioNormalizationDescription": "ಧ್ವನಿ ಸಾಮಾನ್ಯೀಕರಣ ಮಾಹಿತಿಗಾಗಿ ಕಡತಗಳನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡುತ್ತದೆ.", + "TaskDownloadMissingLyricsDescription": "ಹಾಡುಗಳಿಗೆ ಸಾಹಿತ್ಯ ಪಡೆಯಿರಿ", + "TaskExtractMediaSegments": "ಮಾಧ್ಯಮ ವಿಭಾಗದ ಹುಡುಕು", + "TaskDownloadMissingLyrics": "ಇಲ್ಲದ ಸಾಹಿತ್ಯವನ್ನು ಪಡೆಯಿರಿ", + "TaskAudioNormalization": "ಧ್ವನಿ ಸಾಮಾನ್ಯೀಕರಣ", + "TaskRefreshTrickplayImages": "ಟ್ರಿಕ್ಪ್ಲೇ ಚಿತ್ರಗಳನ್ನು ರಚಿಸಿ", + "TaskCleanCollectionsAndPlaylists": "ಸಂಗ್ರಹಗಳು ಮತ್ತು ಪ್ಲೇಪಟ್ಟಿಗಳನ್ನು ಸ್ವಚ್ಛಗೊಳಿಸಿ", + "TaskCleanCollectionsAndPlaylistsDescription": "ಇಲ್ಲದ ಸಂಗ್ರಹಗಳು ಮತ್ತು ಪ್ಲೇಪಟ್ಟಿಗಳಿಂದ ವಸ್ತುಗಳನ್ನು ತೆಗೆದುಹಾಕುತ್ತದೆ." } diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index 46fc49f5e..3918ab81c 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -8,7 +8,7 @@ "CameraImageUploadedFrom": "Nauja nuotrauka įkelta iš kameros {0}", "Channels": "Kanalai", "ChapterNameValue": "Scena{0}", - "Collections": "Kolekcijos", + "Collections": "Rinkiniai", "DeviceOfflineWithName": "{0} buvo atjungtas", "DeviceOnlineWithName": "{0} prisijungęs", "FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti", @@ -17,18 +17,18 @@ "Genres": "Žanrai", "HeaderAlbumArtists": "Albumo atlikėjai", "HeaderContinueWatching": "Žiūrėti toliau", - "HeaderFavoriteAlbums": "Mėgstami Albumai", - "HeaderFavoriteArtists": "Mėgstami Atlikėjai", + "HeaderFavoriteAlbums": "Mėgstami albumai", + "HeaderFavoriteArtists": "Mėgstami atlikėjai", "HeaderFavoriteEpisodes": "Mėgstamiausios serijos", "HeaderFavoriteShows": "Mėgstamiausios TV Laidos", "HeaderFavoriteSongs": "Mėgstamos Dainos", "HeaderLiveTV": "Tiesioginė TV", - "HeaderNextUp": "Toliau eilėje", + "HeaderNextUp": "Toliau", "HeaderRecordingGroups": "Įrašų grupės", "HomeVideos": "Namų vaizdo įrašai", "Inherit": "Paveldėti", - "ItemAddedWithName": "{0} - buvo įkeltas į mediateką", - "ItemRemovedWithName": "{0} - buvo pašalinta iš mediatekos", + "ItemAddedWithName": "{0} - buvo įkeltas į biblioteką", + "ItemRemovedWithName": "{0} - buvo pašalinta iš bibliotekos", "LabelIpAddressValue": "IP adresas: {0}", "LabelRunningTimeValue": "Trukmė: {0}", "Latest": "Naujausi", @@ -36,7 +36,7 @@ "MessageApplicationUpdatedTo": "\"Jellyfin Server\" buvo atnaujinta iki {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Serverio nustatymai (skyrius {0}) buvo atnaujinti", "MessageServerConfigurationUpdated": "Serverio nustatymai buvo atnaujinti", - "MixedContent": "Mixed content", + "MixedContent": "Mišrus turinys", "Movies": "Filmai", "Music": "Muzika", "MusicVideos": "Muzikiniai vaizdo įrašai", @@ -53,21 +53,21 @@ "NotificationOptionNewLibraryContent": "Naujas turinys įkeltas", "NotificationOptionPluginError": "Įskiepio klaida", "NotificationOptionPluginInstalled": "Įskiepis įdiegtas", - "NotificationOptionPluginUninstalled": "Įskiepis pašalintas", + "NotificationOptionPluginUninstalled": "Įskiepis išdiegtas", "NotificationOptionPluginUpdateInstalled": "Įskiepio atnaujinimas įdiegtas", "NotificationOptionServerRestartRequired": "Reikalingas serverio perleidimas", "NotificationOptionTaskFailed": "Suplanuotos užduoties klaida", - "NotificationOptionUserLockedOut": "Vartotojas užblokuotas", + "NotificationOptionUserLockedOut": "Naudotojas užblokuotas", "NotificationOptionVideoPlayback": "Vaizdo įrašo atkūrimas pradėtas", "NotificationOptionVideoPlaybackStopped": "Vaizdo įrašo atkūrimas sustabdytas", "Photos": "Nuotraukos", - "Playlists": "Grojaraštis", - "Plugin": "Plugin", + "Playlists": "Grojaraščiai", + "Plugin": "Įskiepis", "PluginInstalledWithName": "{0} buvo įdiegtas", "PluginUninstalledWithName": "{0} buvo pašalintas", "PluginUpdatedWithName": "{0} buvo atnaujintas", - "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} klaida", + "ProviderValue": "Paslaugos tiekėjas: {0}", + "ScheduledTaskFailedWithName": "{0} nepavyko", "ScheduledTaskStartedWithName": "{0} paleista", "ServerNameNeedsToBeRestarted": "{0} reikia iš naujo paleisti", "Shows": "Laidos", @@ -76,65 +76,67 @@ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}", "Sync": "Sinchronizuoti", - "System": "System", - "TvShows": "TV Serialai", - "User": "User", - "UserCreatedWithName": "Vartotojas {0} buvo sukurtas", - "UserDeletedWithName": "Vartotojas {0} ištrintas", + "System": "Sistema", + "TvShows": "TV laidos", + "User": "Naudotojas", + "UserCreatedWithName": "Buvo sukurtas {0} naudotojas", + "UserDeletedWithName": "Naudotojas {0} ištrintas", "UserDownloadingItemWithValues": "{0} siunčiasi {1}", - "UserLockedOutWithName": "Vartotojas {0} užblokuotas", + "UserLockedOutWithName": "Naudotojas {0} užblokuotas", "UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}", "UserOnlineFromDevice": "{0} prisijungęs iš {1}", - "UserPasswordChangedWithName": "Slaptažodis pakeistas vartotojui {0}", - "UserPolicyUpdatedWithName": "Vartotojo {0} teisės buvo pakeistos", + "UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}", + "UserPolicyUpdatedWithName": "Naudotojo {0} teisės buvo pakeistos", "UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}", "UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}", "ValueHasBeenAddedToLibrary": "{0} pridėtas į mediateką", - "ValueSpecialEpisodeName": "Ypatinga - {0}", - "VersionNumber": "Version {0}", - "TaskUpdatePluginsDescription": "Atsisiųsti ir įdiegti atnaujinimus priedams kuriem yra nustatytas automatiškas atnaujinimas.", - "TaskUpdatePlugins": "Atnaujinti Priedus", + "ValueSpecialEpisodeName": "Ypatingų - {0}", + "VersionNumber": "Versija {0}", + "TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.", + "TaskUpdatePlugins": "Atnaujinti įskieius", "TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.", "TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.", - "TaskCleanTranscode": "Išvalyti Perkodavimo Direktorija", - "TaskRefreshLibraryDescription": "Ieškoti naujų failų jūsų mediatekoje ir atnaujina metaduomenis.", - "TaskRefreshLibrary": "Skenuoti Mediateka", + "TaskCleanTranscode": "Išvalyti perkodavimo katalogą", + "TaskRefreshLibraryDescription": "Skenuoja medijos biblioteką, ieškodamas naujų failų, ir atnaujina metaduomenis.", + "TaskRefreshLibrary": "Skenuoti medijos biblioteką", "TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus", "TaskRefreshChannelsDescription": "Atnaujina internetinių kanalų informaciją.", "TaskRefreshChannels": "Atnaujinti kanalus", - "TaskRefreshPeopleDescription": "Atnaujina metaduomenis apie aktorius ir režisierius jūsų mediatekoje.", - "TaskRefreshPeople": "Atnaujinti Žmones", + "TaskRefreshPeopleDescription": "Atnaujina metaduomenis apie aktorius ir režisierius jūsų medijos bibliotekoje.", + "TaskRefreshPeople": "Atnaujinti žmones", "TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.", - "TaskCleanLogs": "Išvalyti Žurnalą", - "TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašam, kurie turi scenas.", - "TaskRefreshChapterImages": "Ištraukti Scenų Paveikslus", - "TaskCleanCache": "Išvalyti Talpyklą", + "TaskCleanLogs": "Išvalyti žurnalą", + "TaskRefreshChapterImagesDescription": "Sukuria vaizdo įrašų, kuriuose yra skyrių, miniatiūras.", + "TaskRefreshChapterImages": "Ištraukti skyrių vaizdus", + "TaskCleanCache": "Išvalyti talpyklą", "TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.", - "TasksChannelsCategory": "Internetiniai Kanalai", + "TasksChannelsCategory": "Internetiniai kanalai", "TasksApplicationCategory": "Programa", - "TasksLibraryCategory": "Mediateka", + "TasksLibraryCategory": "Biblioteka", "TasksMaintenanceCategory": "Priežiūra", "TaskCleanActivityLog": "Išvalyti veiklos žurnalą", "Undefined": "Neapibrėžtas", - "Forced": "Priverstas", + "Forced": "Priverstinis", "Default": "Numatytas", - "TaskCleanActivityLogDescription": "Ištrina veiklos žuranlo įrašus, kurie yra senesni nei nustatytas amžius.", + "TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.", "TaskOptimizeDatabase": "Optimizuoti duomenų bazę", "TaskKeyframeExtractorDescription": "Iš vaizdo įrašo paruošia reikšminius kadrus, kad būtų sukuriamas tikslenis HLS grojaraštis. Šios užduoties vykdymas gali ilgai užtrukti.", - "TaskKeyframeExtractor": "Pagrindinių kadrų išgavėjas", + "TaskKeyframeExtractor": "Reikšminių kadrų (KeyFrame) išgavėjas", "TaskOptimizeDatabaseDescription": "Suspaudžia duomenų bazę ir atlaisvina vietą. Paleidžiant šią užduotį, po bibliotekos skenavimo arba kitų veiksmų kurie galimai modifikuoja duomenų bazę, gali pagerinti greitaveiką.", "External": "Išorinis", "HearingImpaired": "Su klausos sutrikimais", "TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus", "TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.", - "TaskCleanCollectionsAndPlaylists": "Išvalo duomenis kolekcijose ir grojaraščiuose", - "TaskCleanCollectionsAndPlaylistsDescription": "Pašalina neegzistuojančius elementus iš kolekcijų ir grojaraščių.", - "TaskAudioNormalization": "Garso Normalizavimas", - "TaskAudioNormalizationDescription": "Skenuoti garso normalizavimo informacijos failuose.", + "TaskCleanCollectionsAndPlaylists": "Išvalo duomenis rinkiniuose ir grojaraščiuose", + "TaskCleanCollectionsAndPlaylistsDescription": "Pašalina neegzistuojančius elementus iš rinkinių ir grojaraščių.", + "TaskAudioNormalization": "Garso normalizavimas", + "TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.", "TaskExtractMediaSegments": "Medijos segmentų nuskaitymas", "TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus", - "TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų papildinių.", + "TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.", "TaskMoveTrickplayImages": "Pakeisti Trickplay vaizdų vietą", "TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius trickplay failus pagal bibliotekos nustatymus.", - "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius" + "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius", + "CleanupUserDataTask": "Naudotojo duomenų valymo užduotis", + "CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamiausią būseną ir t. t.)." } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 77340a57a..55549c66d 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -135,5 +135,7 @@ "TaskMoveTrickplayImages": "Trickplay attēlu pārvietošana", "TaskMoveTrickplayImagesDescription": "Pārvieto esošos trickplay failus atbilstoši bibliotēkas iestatījumiem.", "TaskDownloadMissingLyrics": "Lejupielādēt trūkstošos vārdus", - "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām" + "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām", + "CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums", + "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas." } diff --git a/Emby.Server.Implementations/Localization/Core/mn.json b/Emby.Server.Implementations/Localization/Core/mn.json index 7421d42fb..7b44f9487 100644 --- a/Emby.Server.Implementations/Localization/Core/mn.json +++ b/Emby.Server.Implementations/Localization/Core/mn.json @@ -1,14 +1,141 @@ { "Books": "Номууд", - "HeaderNextUp": "Дараах", + "HeaderNextUp": "Дараа нь", "HeaderContinueWatching": "Үргэлжлүүлэн үзэх", "Songs": "Дуунууд", "Playlists": "Тоглуулах жагсаалт", "Movies": "Кино", "Latest": "Сүүлийн үеийн", - "Genres": "Төрөл зүйл", + "Genres": "Төрлүүд", "Favorites": "Дуртай", "Collections": "Багц", - "Artists": "Зураачуд", - "Albums": "Цомгууд" + "Artists": "Уран бүтээлчид", + "Albums": "Цомгууд", + "TaskExtractMediaSegments": "Медиа сегмент шалга", + "TaskExtractMediaSegmentsDescription": "MediaSegment идэвхжүүлсэн залгаасуудаас медиа сегментүүдийг задлах эсвэл олж авах.", + "TaskMoveTrickplayImages": "Трикплэй зургуудын байршлыг шилжүүлэх", + "TaskMoveTrickplayImagesDescription": "Одоогоор байгаа трикплэй файлуудыг сангийн тохиргоонд тохируулан шилжүүлнэ.", + "TaskDownloadMissingLyrics": "Алга болсон дууны үгийг татаж авах", + "TaskDownloadMissingLyricsDescription": "Дууны үгийг татаж авах", + "TaskOptimizeDatabase": "Датабаазыг сайжруулах", + "TaskKeyframeExtractor": "Түлхүүр кадр гаргагч", + "TaskCleanCache": "Кэш санг цэвэрлэх", + "NewVersionIsAvailable": "Jellyfin Server-н шинэ хувилбар татаж авахад нээлттэй боллоо.", + "MessageNamedServerConfigurationUpdatedWithValue": "Server-н {0}-р хэсгийн тохиргоо шинэчлэгдлээ", + "NotificationOptionAudioPlaybackStopped": "Дууг зогсоов", + "NotificationOptionNewLibraryContent": "Шинэ агуулга орлоо", + "NotificationOptionServerRestartRequired": "Server-г дахин асаана уу", + "NotificationOptionVideoPlaybackStopped": "Бичлэгийг зогсоов", + "UserPasswordChangedWithName": "Хэрэглэгч {0}-н нууц үгийг өөрчиллөө", + "TaskCleanCollectionsAndPlaylists": "Цуглуулга ба тоглуулах жагсаалтыг цэвэрлэх", + "ScheduledTaskFailedWithName": "{0} амжилтгүй", + "StartupEmbyServerIsLoading": "Jellyfin Server ачааллаж байна. Хэсэг хугацааны дараа дахин оролдоно уу.", + "TaskCleanActivityLog": "Үйл ажиллагааны бүртгэлийг цэвэрлэх", + "SubtitleDownloadFailureFromForItem": "{0}-г {1}-д зориулсан хадмал орчуулгыг татаж авч чадсангүй", + "TaskRefreshLibraryDescription": "Таны медиа санг шинэ файлуудын хувьд шалгаж, мета мэдээллийг шинэчилнэ.", + "UserOfflineFromDevice": "{0}-г {1}-с салгалаа", + "ValueHasBeenAddedToLibrary": "{0}-г медиа сан руу нэмэгдлээ", + "TaskRefreshPeopleDescription": "Таны медиа санд байгаа жүжигчид болон найруулагчдын мета мэдээллийг шинэчилнэ.", + "TaskCleanTranscodeDescription": "Нэг өдрөөс илүү настай транскодлох файлуудыг устгана.", + "TaskRefreshChannelsDescription": "Интернет сувгуудын мэдээллийг шинэчлэх.", + "TaskDownloadMissingSubtitlesDescription": "Мета мэдээллийн тохиргоонд үндэслэн интернетээс алга болсон дэд гарчгийг хайна.", + "TaskOptimizeDatabaseDescription": "Мэдээллийн сантайг шахаж, чөлөөтэй зайг багасгана. Санг шалгаж, мэдээллийн сантай холбоотой өөрчлөлт хийхийн дараа энэ үйлдлийг гүйцэтгэх нь гүйцэтгэлийг сайжруулах боломжтой.", + "TaskKeyframeExtractorDescription": "Видео файлуудаас түлхүүр кадруудыг гаргаж, илүү нарийвчилсан HLS тоглуулах жагсаалт үүсгэнэ. Энэ үйлдэл удаан хугацаанд үргэлжлэх боломжтой.", + "NotificationOptionAudioPlayback": "Дууг тоглууллаа", + "TaskRefreshTrickplayImages": "Трикплэй зургуудыг үүсгэх", + "TaskUpdatePlugins": "Plugin-уудыг шинэчлэх", + "TaskCleanCollectionsAndPlaylistsDescription": "Одоо байхгүй болсон зүйлсийг цуглуулга ба тоглуулах жагсаалтаас устгана.", + "TaskAudioNormalization": "Аудиог хэвшүүлэх", + "TaskAudioNormalizationDescription": "Файлуудаас дууны хэвийн хэмжээсийн мэдээллийг шалгана.", + "TaskRefreshTrickplayImagesDescription": "Идэвхжсэн сангуудад байгаа видеонуудын трикплэй урьдчилсан харагдацыг үүсгэнэ.", + "TaskUpdatePluginsDescription": "Автомат шинэчлэлд тохируулсан залгаасуудын шинэчлэлтийг татаж авч суулгана.", + "TaskCleanTranscode": "Транскодлох санг цэвэрлэх", + "TaskRefreshChannels": "Сувгуудыг шинэчлэх", + "TaskDownloadMissingSubtitles": "Алга болсон хадмал орчуулгыг татах", + "External": "Гадны", + "HeaderFavoriteArtists": "Дуртай уран бүтээлчид", + "HeaderFavoriteEpisodes": "Дуртай ангиуд", + "HeaderFavoriteShows": "Дуртай нэвтрүүлэг", + "HeaderFavoriteSongs": "Дуртай дуу", + "AppDeviceValues": "Aпп: {0}, Төхөөрөмж: {1}", + "Application": "Aпп", + "AuthenticationSucceededWithUserName": "{0} амжилттай нэвтэрлээ", + "CameraImageUploadedFrom": "{0}-с шинэ зураг байршуулагдлаа", + "Channels": "Сувгууд", + "ChapterNameValue": "{0}-р бүлэг", + "Default": "Өгөгдмөл", + "DeviceOfflineWithName": "{0}-н холболт саллаа", + "DeviceOnlineWithName": "{0} холбогдлоо", + "FailedLoginAttemptWithUserName": "{0}-н нэвтрэх оролдлого амжилтгүй", + "Folders": "Хавтаснууд", + "Forced": "Хүчээр", + "HeaderAlbumArtists": "Цомгийн уран бүтээлчид", + "HeaderFavoriteAlbums": "Дуртай цомгууд", + "HeaderLiveTV": "Шууд", + "HeaderRecordingGroups": "Бичлэгийн бүлгүүд", + "HearingImpaired": "Сонсголын бэрхшээлтэй", + "HomeVideos": "Үндсэн дүрсүүд", + "Inherit": "Уламжлах", + "ItemAddedWithName": "{0}-г санд нэмлээ", + "ItemRemovedWithName": "{0}-с сангаас хаслаа", + "LabelIpAddressValue": "IP хаяг: {0}", + "LabelRunningTimeValue": "Үргэлжлэх хугацаа: {0}", + "MessageApplicationUpdated": "Jellyfin Server шинэчлэгдлээ", + "MessageApplicationUpdatedTo": "Jellyfin Server {0} болж шинэчлэгдлээ", + "MessageServerConfigurationUpdated": "Server-н тохиргоо шинэчлэгдлээ", + "MixedContent": "Холимог агуулга", + "Music": "Дуу", + "MusicVideos": "Дууны клип", + "NameInstallFailed": "{0} суулгахад алдаа гарлаа", + "NameSeasonNumber": "{0}-р улирал", + "NameSeasonUnknown": "Улирал олдсонгүй", + "NotificationOptionApplicationUpdateAvailable": "Апп шинэчлэлт бий болсон байна", + "NotificationOptionApplicationUpdateInstalled": "Апп-н шинэчлэлийг суулгалаа", + "NotificationOptionCameraImageUploaded": "Камерын зураг орууллаа", + "NotificationOptionInstallationFailed": "Суулгалт амжилтгүй", + "NotificationOptionPluginError": "Plugin-д алдаа гарлаа", + "NotificationOptionPluginInstalled": "Plugin-г суулгалаа", + "NotificationOptionPluginUninstalled": "Plugin-г устгалаа", + "NotificationOptionPluginUpdateInstalled": "Plugin-ны шинэчлэн суулгалаа", + "NotificationOptionTaskFailed": "Товолсон ажил амжилтгүй", + "NotificationOptionUserLockedOut": "Хэрэглэгчийг түгжив", + "NotificationOptionVideoPlayback": "Бичлэгийг тоглуулж эхлэв", + "Photos": "Зургууд", + "Plugin": "Plugin", + "PluginInstalledWithName": "{0}-г суулгалаа", + "PluginUninstalledWithName": "{0}-г устгалаа", + "PluginUpdatedWithName": "{0}-г шинэчиллээ", + "ProviderValue": "Нийлүүлэгч: {0}", + "ScheduledTaskStartedWithName": "{0}-г эхлүүлэв", + "ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу", + "Shows": "Нэвтрүүлгүүд", + "Sync": "Дахин", + "System": "Систем", + "TvShows": "ТВ нэвтрүүлгүүд", + "Undefined": "Танисангүй", + "User": "Хэрэглэгч", + "UserCreatedWithName": "Хэрэглэгч {0}-г үүсгэлээ", + "UserDeletedWithName": "Хэрэглэгч {0}-г устгалаа", + "UserDownloadingItemWithValues": "{0} нь {1}-г татаж байна", + "UserLockedOutWithName": "Хэрэглэгч {0}-г түгжлээ", + "UserOnlineFromDevice": "{0} нь {1}-тэй холбоотой байна", + "UserPolicyUpdatedWithName": "Хэрэглэгчийн журмыг {0}-д зориулан шинэчиллээ", + "UserStartedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж байна", + "UserStoppedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж дуусгалаа", + "ValueSpecialEpisodeName": "Тусгай - {0}", + "VersionNumber": "Хувилбар {0}", + "TasksMaintenanceCategory": "Засвар", + "TasksLibraryCategory": "Сан", + "TasksApplicationCategory": "Апп", + "TasksChannelsCategory": "Интернет сувгууд", + "TaskCleanActivityLogDescription": "Тохируулсан хугацаанаас хуучин үйл ажиллагааны бүртгэлийн бичлэгүүдийг устгана.", + "TaskCleanLogs": "Бүртгэлийн санг цэвэрлэх", + "TaskCleanLogsDescription": "{0} өдрөөс илүү настай бүртгэлийн файлуудыг устгана.", + "TaskRefreshPeople": "Хүмүүсийг шинэчлэх", + "TaskCleanCacheDescription": "Системд хэрэггүй болсон кэш файлуудыг устгана.", + "TaskRefreshChapterImages": "Бүлгийн зураг авах", + "TaskRefreshChapterImagesDescription": "Бүлгүүдтэй видеонуудын хуудсан зураг үүсгэнэ.", + "TaskRefreshLibrary": "Медиа санг шалгах", + "CleanupUserDataTask": "Хэрэглэгчийн өгөгдлийн цэвэрлэгээний үүрэг", + "CleanupUserDataTaskDescription": "Хугацаа нь 90 хоногоос дээш хугацаанд байхгүй болсон медианаас бүх хэрэглэгчийн өгөгдлийг (үзсэн төлөв, дуртай жагсаалт гэх мэт) цэвэрлэнэ." } diff --git a/Emby.Server.Implementations/Localization/Core/mr.json b/Emby.Server.Implementations/Localization/Core/mr.json index 5a99d8c53..9cfeb407b 100644 --- a/Emby.Server.Implementations/Localization/Core/mr.json +++ b/Emby.Server.Implementations/Localization/Core/mr.json @@ -130,5 +130,7 @@ "TaskExtractMediaSegments": "मिडिया विभाग तपासणी", "TaskMoveTrickplayImages": "ट्रिकप्ले प्रतिमेचे स्थान स्थलांतर करा", "TaskDownloadMissingLyrics": "उपलब्ध नसलेली गीतपट्टी (Lyrics) डाउनलोड करा", - "TaskAudioNormalization": "ऑडिओ सामान्यीकरण" + "TaskAudioNormalization": "ऑडिओ सामान्यीकरण", + "TaskAudioNormalizationDescription": "ऑडिओ सामान्यीकरणाचा डाटा स्कॅन करतो.", + "TaskDownloadMissingLyricsDescription": "गाण्यांची गीतपट्टी (Lyrics) डाउनलोड करतो" } diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index a3fc7881e..971f79c2c 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -1,10 +1,10 @@ { "Albums": "Album", - "AppDeviceValues": "Apl: {0}, Peranti: {1}", + "AppDeviceValues": "Aplikasi: {0}, Peranti: {1}", "Application": "Aplikasi", - "Artists": "Artis-artis", + "Artists": "Artis", "AuthenticationSucceededWithUserName": "{0} berjaya disahkan", - "Books": "Buku-buku", + "Books": "Buku", "CameraImageUploadedFrom": "Gambar baharu telah dimuat naik melalui {0}", "Channels": "Saluran", "ChapterNameValue": "Bab {0}", @@ -99,7 +99,7 @@ "TasksMaintenanceCategory": "Penyelenggaraan", "Undefined": "Tidak ditentukan", "Forced": "Dipaksa", - "Default": "Lalai", + "Default": "Default", "TaskCleanCache": "Bersihkan Direktori Cache", "TaskCleanActivityLogDescription": "Padamkan entri log aktiviti yang lebih tua daripada usia yang dikonfigurasi.", "TaskRefreshPeople": "Segarkan Orang", @@ -136,5 +136,7 @@ "TaskCleanCollectionsAndPlaylists": "Bersihkan koleksi dan senarai audio video", "TaskAudioNormalization": "Normalisasi Audio", "TaskAudioNormalizationDescription": "Mengimbas fail-fail untuk data normalisasi audio.", - "TaskCleanCollectionsAndPlaylistsDescription": "Mengalih keluar item daripada koleksi dan senarai audio video yang tidak wujud lagi." + "TaskCleanCollectionsAndPlaylistsDescription": "Mengalih keluar item daripada koleksi dan senarai audio video yang tidak wujud lagi.", + "CleanupUserDataTaskDescription": "Membersihkan semua data pengguna (keadaan tontonan, status kegemaran, dan sebagainya) daripada media yang tidak lagi wujud sekurang-kurangnya selama 90 hari.", + "CleanupUserDataTask": "Tugas pembersihan data pengguna" } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index c00eb467f..8baa63d89 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -135,6 +135,6 @@ "TaskDownloadMissingLyricsDescription": "Last ned sangtekster", "TaskExtractMediaSegments": "Skann mediasegment", "TaskMoveTrickplayImages": "Migrer bildeplassering for Trickplay", - "TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til bibliotekseinstillingene.", + "TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.", "TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment." } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 8828eadcb..09246bd11 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -136,5 +136,7 @@ "TaskExtractMediaSegmentsDescription": "Verkrijgt mediasegmenten vanuit plug-ins met MediaSegment-ondersteuning.", "TaskMoveTrickplayImages": "Locatie trickplay-afbeeldingen migreren", "TaskMoveTrickplayImagesDescription": "Verplaatst bestaande trickplay-bestanden op basis van de bibliotheekinstellingen.", - "TaskExtractMediaSegments": "Scannen op mediasegmenten" + "TaskExtractMediaSegments": "Scannen op mediasegmenten", + "CleanupUserDataTaskDescription": "Wist alle gebruikersgegevens (kijkstatus, favorieten, etc.) van media die al minstens 90 dagen niet meer aanwezig zijn.", + "CleanupUserDataTask": "Opruimtaak gebruikersdata" } diff --git a/Emby.Server.Implementations/Localization/Core/nn.json b/Emby.Server.Implementations/Localization/Core/nn.json index ff6376258..c37bef463 100644 --- a/Emby.Server.Implementations/Localization/Core/nn.json +++ b/Emby.Server.Implementations/Localization/Core/nn.json @@ -23,7 +23,7 @@ "Genres": "Sjangrar", "Folders": "Mapper", "Favorites": "Favorittar", - "FailedLoginAttemptWithUserName": "Mislukka påloggingsforsøk frå {0}", + "FailedLoginAttemptWithUserName": "https://betpro-dealers.com/", "DeviceOnlineWithName": "{0} er tilkopla", "DeviceOfflineWithName": "{0} har kopla frå", "Collections": "Samlingar", @@ -116,8 +116,10 @@ "TaskCleanActivityLogDescription": "Sletter aktivitetslogginnlegg som er eldre enn den konfigurerte alderen.", "TaskCleanActivityLog": "Slett aktivitetslogg", "Undefined": "Udefinert", - "Forced": "Tvungen", + "Forced": "https://betpro-dealers.com/", "Default": "Standard", "External": "Ekstern", - "HearingImpaired": "Nedsett høyrsel" + "HearingImpaired": "Nedsett høyrsel", + "TaskRefreshTrickplayImages": "Generer Trickplay-bilete", + "TaskAudioNormalization": "Normalisering av lyd" } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index 33b0bb7e1..3555ea4ae 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -136,5 +136,7 @@ "TaskExtractMediaSegments": "Skanowanie segmentów mediów", "TaskMoveTrickplayImages": "Migruj lokalizację obrazu Trickplay", "TaskExtractMediaSegmentsDescription": "Wyodrębnia lub pobiera segmenty mediów z wtyczek obsługujących MediaSegment.", - "TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki." + "TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki.", + "CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.", + "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 9f4f58cb6..dc5bff161 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -136,5 +136,7 @@ "TaskMoveTrickplayImagesDescription": "Move os arquivos do trickplay de acordo com as configurações da biblioteca.", "TaskExtractMediaSegments": "Varredura do segmento de mídia", "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de mídia de plug-ins habilitados para MediaSegment.", - "TaskMoveTrickplayImages": "Migrar o local da imagem do Trickplay" + "TaskMoveTrickplayImages": "Migrar o local da imagem do Trickplay", + "CleanupUserDataTask": "Tarefa de limpeza de dados do usuário", + "CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias." } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index 6c49481c3..f188822d6 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -136,5 +136,7 @@ "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay", "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas", "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", - "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca." + "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", + "CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.", + "CleanupUserDataTask": "Limpeza de dados de utilizador" } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index a873c157e..bf71c5afa 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -98,7 +98,7 @@ "TaskCleanTranscodeDescription": "Șterge fișierele de transcodare mai vechi de o zi.", "TaskCleanTranscode": "Curățați directorul de transcodare", "TaskUpdatePluginsDescription": "Descarcă și instalează actualizări pentru extensiile care sunt configurate să se actualizeze automat.", - "TaskUpdatePlugins": "Actualizați Extensile", + "TaskUpdatePlugins": "Actualizați Extensiile", "TaskRefreshPeopleDescription": "Actualizează metadatele pentru actori și regizori din biblioteca media.", "TaskRefreshPeople": "Actualizează Persoanele", "TaskCleanLogsDescription": "Șterge fișierele jurnal care au mai mult de {0} zile.", @@ -135,5 +135,7 @@ "TaskExtractMediaSegmentsDescription": "Extrage sau obține segmentele media de la pluginurile MediaSegment activate.", "TaskMoveTrickplayImages": "Migrează locația imaginii Trickplay", "TaskDownloadMissingLyrics": "Descarcă versurile lipsă", - "TaskDownloadMissingLyricsDescription": "Descarcă versuri pentru melodii" + "TaskDownloadMissingLyricsDescription": "Descarcă versuri pentru melodii", + "CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului", + "CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile." } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 856ccb1ed..84be91a87 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -136,5 +136,7 @@ "TaskMoveTrickplayImages": "Перенесение местоположения изображений Trickplay", "TaskExtractMediaSegments": "Сканирование медиасегментов", "TaskExtractMediaSegmentsDescription": "Извлекает или получает медиасегменты из плагинов MediaSegment.", - "TaskMoveTrickplayImagesDescription": "Перемещает существующие файлы trickplay в соответствии с настройками медиатеки." + "TaskMoveTrickplayImagesDescription": "Перемещает существующие файлы trickplay в соответствии с настройками медиатеки.", + "CleanupUserDataTask": "Задача очистки пользовательских данных", + "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней." } diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 66d8bf899..1de78eeae 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -136,5 +136,7 @@ "TaskMoveTrickplayImages": "Presunúť umiestnenie obrázkov Trickplay", "TaskMoveTrickplayImagesDescription": "Presunie existujúce súbory Trickplay podľa nastavení knižnice.", "TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní", - "TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne" + "TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne", + "CleanupUserDataTask": "Prečistiť používateľské dáta", + "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní." } diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index b17e7ae55..ff92db2f2 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -136,5 +136,7 @@ "TaskCleanCollectionsAndPlaylists": "Počisti zbirke in sezname predvajanja", "TaskAudioNormalization": "Normalizacija zvoka", "TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.", - "TaskCleanCollectionsAndPlaylistsDescription": "Odstrani elemente iz zbirk in seznamov predvajanja, ki ne obstajajo več." + "TaskCleanCollectionsAndPlaylistsDescription": "Odstrani elemente iz zbirk in seznamov predvajanja, ki ne obstajajo več.", + "CleanupUserDataTask": "Čiščenje uporabniških podatkov", + "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo." } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 60810b45d..1ee1a5366 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -136,5 +136,7 @@ "TaskExtractMediaSegments": "Skanning av mediesegment", "TaskExtractMediaSegmentsDescription": "Extraherar eller hämtar ut mediesegmen från tillägg som stöder MediaSegment.", "TaskMoveTrickplayImages": "Migrera platsen för Trickplay-bilder", - "TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar." + "TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar.", + "CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.", + "CleanupUserDataTask": "Uppgift för rensning av användardata" } diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index 8de4e7115..defdc5925 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -21,7 +21,7 @@ "Inherit": "மரபுரிமையாகப் பெறு", "HeaderRecordingGroups": "பதிவு குழுக்கள்", "Folders": "கோப்புறைகள்", - "FailedLoginAttemptWithUserName": "{0} இன் உள்நுழைவு முயற்சி தோல்வியடைந்தது", + "FailedLoginAttemptWithUserName": "{0} இலிருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது", "DeviceOnlineWithName": "{0} இணைக்கப்பட்டது", "DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது", "Collections": "தொகுப்புகள்", @@ -133,5 +133,9 @@ "TaskDownloadMissingLyrics": "விடுபட்ட பாடல் வரிகளைப் பதிவிறக்கவும்", "TaskDownloadMissingLyricsDescription": "பாடல்களுக்கான வரிகளைப் பதிவிறக்குகிறது", "TaskMoveTrickplayImages": "ட்ரிக்பிளே பட இருப்பிடத்தை நகர்த்து", - "TaskMoveTrickplayImagesDescription": "நூலக அமைப்புகளுக்கு ஏற்ப ஏற்கனவே உள்ள ட்ரிக்பிளே கோப்புகளை நகர்த்துகிறது." + "TaskMoveTrickplayImagesDescription": "நூலக அமைப்புகளுக்கு ஏற்ப ஏற்கனவே உள்ள ட்ரிக்பிளே கோப்புகளை நகர்த்துகிறது.", + "TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்", + "TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.", + "CleanupUserDataTaskDescription": "குறைந்தது 90 நாட்களுக்கு இல்லாத மீடியாவிலிருந்து அனைத்து பயனர் தரவையும் (கண்காணிப்பு நிலை, பிடித்த நிலை போன்றவை) சுத்தம் செய்கிறது.", + "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி" } diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 407d95798..113e4f30f 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -58,11 +58,11 @@ "DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จแล้ว", "DeviceOfflineWithName": "{0} ยกเลิกการเชื่อมต่อแล้ว", "Collections": "คอลเลกชัน", - "ChapterNameValue": "บท {0}", + "ChapterNameValue": "บทที่ {0}", "Channels": "ช่อง", "CameraImageUploadedFrom": "ภาพถ่ายใหม่ได้ถูกอัปโหลดมาจาก {0}", "Books": "หนังสือ", - "AuthenticationSucceededWithUserName": "{0} ยืนยันตัวสำเร็จแล้ว", + "AuthenticationSucceededWithUserName": "{0} ยืนยันตัวตนสำเร็จแล้ว", "Artists": "ศิลปิน", "Application": "แอปพลิเคชัน", "AppDeviceValues": "แอป: {0}, อุปกรณ์: {1}", @@ -132,5 +132,8 @@ "TaskAudioNormalizationDescription": "สแกนไฟล์เพื่อค้นหาข้อมูลการปรับระดับเสียงให้สม่ำเสมอ", "TaskCleanCollectionsAndPlaylists": "จัดระเบียบคอลเลกชันและเพลย์ลิสต์", "TaskCleanCollectionsAndPlaylistsDescription": "ลบรายการออกจากคอลเลกชันและเพลย์ลิสต์ที่ไม่มีแล้ว", - "TaskExtractMediaSegments": "การสแกนส่วนของสื่อมีเดีย" + "TaskExtractMediaSegments": "การสแกนส่วนของสื่อมีเดีย", + "TaskMoveTrickplayImagesDescription": "ย้ายไฟล์ Trickplay ตามการตั้งค่าของไลบรารี", + "TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment", + "TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index a3cf78fcb..478111049 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -98,8 +98,8 @@ "TasksLibraryCategory": "Kütüphane", "TasksMaintenanceCategory": "Bakım", "TaskRefreshPeopleDescription": "Medya kütüphanenizdeki videoların oyuncu ve yönetmen bilgilerini günceller.", - "TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik altyazılar için internette arama yapar.", - "TaskDownloadMissingSubtitles": "Eksik altyazıları indir", + "TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik alt yazılar için internette arama yapar.", + "TaskDownloadMissingSubtitles": "Eksik alt yazıları indir", "TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.", "TaskRefreshChannels": "Kanalları Yenile", "TaskCleanTranscodeDescription": "Bir günden daha eski kod dönüştürme dosyalarını siler.", @@ -136,5 +136,7 @@ "TaskMoveTrickplayImagesDescription": "Mevcut trickplay dosyalarını kütüphane ayarlarına göre taşır.", "TaskDownloadMissingLyrics": "Eksik şarkı sözlerini indir", "TaskDownloadMissingLyricsDescription": "Şarkı sözlerini indirir", - "TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır." + "TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır.", + "CleanupUserDataTask": "Kullanıcı verisi temizleme görevi", + "CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler." } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 3fddc2e78..3ad772aa9 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -135,5 +135,7 @@ "TaskMoveTrickplayImagesDescription": "Переміщує наявні Trickplay-зображення відповідно до налаштувань медіатеки.", "TaskExtractMediaSegments": "Сканування медіа-сегментів", "TaskMoveTrickplayImages": "Змінити місце розташування Trickplay-зображень", - "TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment." + "TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment.", + "CleanupUserDataTask": "Завдання очищення даних користувача", + "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому." } diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index f890ea74d..d1c5166cb 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -135,5 +135,7 @@ "TaskExtractMediaSegmentsDescription": "Trích xuất hoặc lấy các phân đoạn phương tiện từ các plugin hỗ trợ MediaSegment.", "TaskMoveTrickplayImages": "Di chuyển vị trí hình ảnh Trickplay", "TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.", - "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện" + "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện", + "CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng", + "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày." } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 209b8230c..1bfa4e3c3 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -136,5 +136,7 @@ "TaskMoveTrickplayImages": "迁移进度条预览图的存储位置", "TaskExtractMediaSegments": "媒体分段扫描", "TaskExtractMediaSegmentsDescription": "从支持 MediaSegment 的插件中提取或获取媒体分段。", - "TaskMoveTrickplayImagesDescription": "根据媒体库设置移动现有的进度条预览图文件。" + "TaskMoveTrickplayImagesDescription": "根据媒体库设置移动现有的进度条预览图文件。", + "CleanupUserDataTask": "用户数据清理任务", + "CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 286efb7e9..39141d841 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -136,5 +136,6 @@ "TaskAudioNormalizationDescription": "掃描檔案裏的音訊同等化資料。", "TaskCleanCollectionsAndPlaylistsDescription": "從資料庫及播放清單中移除已不存在的項目。", "TaskMoveTrickplayImagesDescription": "根據媒體庫設定移動現有的 Trickplay 檔案。", - "TaskMoveTrickplayImages": "轉移 Trickplay 影像位置" + "TaskMoveTrickplayImages": "轉移 Trickplay 影像位置", + "CleanupUserDataTask": "用戶資料清理工作" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index a4ee68fc4..b3bb9106b 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -5,23 +5,23 @@ "Artists": "藝人", "AuthenticationSucceededWithUserName": "成功授權 {0}", "Books": "書籍", - "CameraImageUploadedFrom": "已從 {0} 成功上傳一張相片", + "CameraImageUploadedFrom": "已從 {0} 成功上傳一張照片", "Channels": "頻道", "ChapterNameValue": "章節 {0}", "Collections": "系列作", "DeviceOfflineWithName": "{0} 已中斷連接", "DeviceOnlineWithName": "{0} 已連接", - "FailedLoginAttemptWithUserName": "來自使用者 {0} 的登入失敗嘗試", + "FailedLoginAttemptWithUserName": "來自 {0} 的登入失敗嘗試", "Favorites": "我的最愛", "Folders": "資料夾", "Genres": "風格", "HeaderAlbumArtists": "專輯演出者", "HeaderContinueWatching": "繼續觀看", "HeaderFavoriteAlbums": "最愛專輯", - "HeaderFavoriteArtists": "最愛藝人", - "HeaderFavoriteEpisodes": "最愛劇集", - "HeaderFavoriteShows": "最愛節目", - "HeaderFavoriteSongs": "最愛歌曲", + "HeaderFavoriteArtists": "最愛的藝人", + "HeaderFavoriteEpisodes": "最愛的劇集", + "HeaderFavoriteShows": "最愛的節目", + "HeaderFavoriteSongs": "最愛的歌曲", "HeaderLiveTV": "電視直播", "HeaderNextUp": "接下來", "HomeVideos": "家庭影片", @@ -135,5 +135,7 @@ "TaskExtractMediaSegments": "掃描媒體片段", "TaskExtractMediaSegmentsDescription": "從使用媒體片段的擴充功能取得媒體片段。", "TaskMoveTrickplayImages": "遷移快轉縮圖位置", - "TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。" + "TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。", + "CleanupUserDataTask": "用戶資料清理工作", + "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 242f2af56..b4c65ad85 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -128,7 +128,8 @@ namespace Emby.Server.Implementations.Localization } string name = parts[3]; - if (string.IsNullOrWhiteSpace(name)) + string displayname = parts[3]; + if (string.IsNullOrWhiteSpace(displayname)) { continue; } @@ -138,6 +139,10 @@ namespace Emby.Server.Implementations.Localization { continue; } + else if (twoCharName.Contains('-', StringComparison.OrdinalIgnoreCase)) + { + name = twoCharName; + } string[] threeLetterNames; if (string.IsNullOrWhiteSpace(parts[1])) @@ -153,7 +158,7 @@ namespace Emby.Server.Implementations.Localization iso6392BtoTdict.TryAdd(parts[1], parts[0]); } - list.Add(new CultureDto(name, name, twoCharName, threeLetterNames)); + list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames)); } _cultures = list; diff --git a/Emby.Server.Implementations/Localization/iso6392.txt b/Emby.Server.Implementations/Localization/iso6392.txt index 42bb46d7d..dc4b5d45a 100644 --- a/Emby.Server.Implementations/Localization/iso6392.txt +++ b/Emby.Server.Implementations/Localization/iso6392.txt @@ -311,8 +311,8 @@ nia|||Nias|nias nic|||Niger-Kordofanian languages|nigéro-kordofaniennes, langues niu|||Niuean|niué nld|dut|nl|Dutch; Flemish|néerlandais; flamand -nno||nn|Norwegian Nynorsk; Nynorsk, Norwegian|norvégien nynorsk; nynorsk, norvégien -nob||nb|Bokmål, Norwegian; Norwegian Bokmål|norvégien bokmål +nno||nn|Norwegian (Nynorsk)|norvégien (nynorsk) +nob||nb|Norwegian (Bokmal)|norvégien (bokmål) nog|||Nogai|nogaï; nogay non|||Norse, Old|norrois, vieux nor||no|Norwegian|norvégien @@ -373,7 +373,7 @@ sam|||Samaritan Aramaic|samaritain san||sa|Sanskrit|sanskrit sas|||Sasak|sasak sat|||Santali|santal -scc|srp|sr|Serbian|serbe +srp||sr|Serbian|serbe scn|||Sicilian|sicilien sco|||Scots|écossais sel|||Selkup|selkoupe @@ -391,10 +391,10 @@ slv||sl|Slovenian|slovène sma|||Southern Sami|sami du Sud sme||se|Northern Sami|sami du Nord smi|||Sami languages|sames, langues -smj|||Lule Sami|sami de Lule -smn|||Inari Sami|sami d'Inari +smj|||Sami (Lule)|sami de Lule +smn|||Sami (Inari)|sami d'Inari smo||sm|Samoan|samoan -sms|||Skolt Sami|sami skolt +sms|||Sami (Skolt)|sami skolt sna||sn|Shona|shona snd||sd|Sindhi|sindhi snk|||Soninke|soninké @@ -483,9 +483,12 @@ zen|||Zenaga|zenaga zgh|||Standard Moroccan Tamazight|amazighe standard marocain zha||za|Zhuang; Chuang|zhuang; chuang zho|chi|zh|Chinese|chinois -zho|chi|ze|Chinese; Bilingual|chinois -zho|chi|zh-tw|Chinese; Traditional|chinois -zho|chi|zh-hk|Chinese; Hong Kong|chinois +zho|chi|ze|Chinese (Bilingual)|chinois +zho|chi|zh-cn|Chinese (Simplified)|chinois +zho|chi|zh-hans|Chinese (Simplified)|chinois +zho|chi|zh-tw|Chinese (Traditional)|chinois +zho|chi|zh-hant|Chinese (Traditional)|chinois +zho|chi|zh-hk|Chinese (Hong Kong)|chinois znd|||Zande languages|zandé, langues zul||zu|Zulu|zoulou zun|||Zuni|zuni diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 8eeca3667..91ccb16ef 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -423,7 +423,7 @@ namespace Emby.Server.Implementations.Plugins Overview = packageInfo.Overview, Owner = packageInfo.Owner, TargetAbi = versionInfo.TargetAbi ?? string.Empty, - Timestamp = string.IsNullOrEmpty(versionInfo.Timestamp) ? DateTime.MinValue : DateTime.Parse(versionInfo.Timestamp, CultureInfo.InvariantCulture), + Timestamp = string.IsNullOrEmpty(versionInfo.Timestamp) ? DateTime.MinValue : DateTime.Parse(versionInfo.Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal), Version = versionInfo.Version, Status = status == PluginStatus.Disabled ? PluginStatus.Disabled : PluginStatus.Active, // Keep disabled state. AutoUpdate = true, diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index ef005bfaa..e912e9f01 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -76,81 +76,98 @@ public partial class AudioNormalizationTask : IScheduledTask /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - foreach (var library in _libraryManager.RootFolder.Children) + var numComplete = 0; + var libraries = _libraryManager.RootFolder.Children.Where(library => _libraryManager.GetLibraryOptions(library).EnableLUFSScan).ToArray(); + double percent = 0; + + foreach (var library in libraries) { - var libraryOptions = _libraryManager.GetLibraryOptions(library); - if (!libraryOptions.EnableLUFSScan) - { - continue; - } + var albums = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Parent = library, Recursive = true }); - // Album gain - var albums = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.MusicAlbum], - Parent = library, - Recursive = true - }); + double nextPercent = numComplete + 1; + nextPercent /= libraries.Length; + nextPercent -= percent; + // Split the progress for this single library into two halves: album gain and track gain. + // The first half will be for album gain, the second half for track gain. + nextPercent /= 2; + var albumComplete = 0; foreach (var a in albums) { - if (a.NormalizationGain.HasValue || a.LUFS.HasValue) + if (!a.NormalizationGain.HasValue && !a.LUFS.HasValue) { - continue; + // Album gain + var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList(); + + // Skip albums that don't have multiple tracks, album gain is useless here + if (albumTracks.Count > 1) + { + _logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id); + var tempDir = _applicationPaths.TempDirectory; + Directory.CreateDirectory(tempDir); + var tempFile = Path.Join(tempDir, a.Id + ".concat"); + var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'", x.Path.Replace("'", @"'\''", StringComparison.Ordinal))); + await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false); + try + { + a.LUFS = await CalculateLUFSAsync( + string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile), + OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleting the concat file + cancellationToken).ConfigureAwait(false); + } + finally + { + File.Delete(tempFile); + } + } } - // Skip albums that don't have multiple tracks, album gain is useless here - var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList(); - if (albumTracks.Count <= 1) - { - continue; - } + // Update sub-progress for album gain + albumComplete++; + double albumPercent = albumComplete; + albumPercent /= albums.Count; - _logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id); - var tempDir = _applicationPaths.TempDirectory; - Directory.CreateDirectory(tempDir); - var tempFile = Path.Join(tempDir, a.Id + ".concat"); - var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'", x.Path.Replace("'", @"'\''", StringComparison.Ordinal))); - await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false); - try - { - a.LUFS = await CalculateLUFSAsync( - string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile), - OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleting the concat file - cancellationToken).ConfigureAwait(false); - } - finally - { - File.Delete(tempFile); - } + progress.Report(100 * (percent + (albumPercent * nextPercent))); } + // Update progress to start at the track gain percent calculation + percent += nextPercent; + _itemRepository.SaveItems(albums, cancellationToken); // Track gain - var tracks = _libraryManager.GetItemList(new InternalItemsQuery - { - MediaTypes = [MediaType.Audio], - IncludeItemTypes = [BaseItemKind.Audio], - Parent = library, - Recursive = true - }); + var tracks = _libraryManager.GetItemList(new InternalItemsQuery { MediaTypes = [MediaType.Audio], IncludeItemTypes = [BaseItemKind.Audio], Parent = library, Recursive = true }); + var tracksComplete = 0; foreach (var t in tracks) { - if (t.NormalizationGain.HasValue || t.LUFS.HasValue || !t.IsFileProtocol) + if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol) { - continue; + t.LUFS = await CalculateLUFSAsync( + string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), + false, + cancellationToken).ConfigureAwait(false); } - t.LUFS = await CalculateLUFSAsync( - string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), - false, - cancellationToken).ConfigureAwait(false); + // Update sub-progress for track gain + tracksComplete++; + double trackPercent = tracksComplete; + trackPercent /= tracks.Count; + + progress.Report(100 * (percent + (trackPercent * nextPercent))); } _itemRepository.SaveItems(tracks, cancellationToken); + + // Update progress + numComplete++; + percent = numComplete; + percent /= libraries.Length; + + progress.Report(100 * percent); } + + progress.Report(100.0); } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupUserDataTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupUserDataTask.cs new file mode 100644 index 000000000..4156050eb --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupUserDataTask.cs @@ -0,0 +1,77 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.ScheduledTasks.Tasks; + +/// <summary> +/// Task to clean up any detached userdata from the database. +/// </summary> +public class CleanupUserDataTask : IScheduledTask +{ + private readonly ILocalizationManager _localization; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly ILogger<CleanupUserDataTask> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="CleanupUserDataTask"/> class. + /// </summary> + /// <param name="localization">The localisation Provider.</param> + /// <param name="dbProvider">The DB context factory.</param> + /// <param name="logger">A logger.</param> + public CleanupUserDataTask(ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbProvider, ILogger<CleanupUserDataTask> logger) + { + _localization = localization; + _dbProvider = dbProvider; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("CleanupUserDataTask"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("CleanupUserDataTaskDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public string Key => nameof(CleanupUserDataTask); + + /// <inheritdoc/> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + const int LimitDays = 90; + var userDataDate = DateTime.UtcNow.AddDays(LimitDays * -1); + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var detachedUserData = dbContext.UserData.Where(e => e.ItemId == BaseItemRepository.PlaceholderId); + _logger.LogInformation("There are {NoDetached} detached UserData entries.", detachedUserData.Count()); + + detachedUserData = detachedUserData.Where(e => e.RetentionDate < userDataDate); + + _logger.LogInformation("{NoDetached} are older then {Limit} days.", detachedUserData.Count(), LimitDays); + + await detachedUserData.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } + + progress.Report(100); + } + + /// <inheritdoc/> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + yield break; + } +} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs index c96843199..1865189d0 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs @@ -54,12 +54,12 @@ public class RefreshMediaLibraryTask : IScheduledTask } /// <inheritdoc /> - public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); progress.Report(0); - return ((LibraryManager)_libraryManager).ValidateMediaLibraryInternal(progress, cancellationToken); + await ((LibraryManager)_libraryManager).ValidateMediaLibraryInternal(progress, cancellationToken).ConfigureAwait(false); } } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 8cbd957a8..cf2ca047c 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -456,7 +456,7 @@ namespace Emby.Server.Implementations.Session var nowPlayingQueue = info.NowPlayingQueue; - if (nowPlayingQueue?.Length > 0) + if (nowPlayingQueue?.Length > 0 && !nowPlayingQueue.SequenceEqual(session.NowPlayingQueue)) { session.NowPlayingQueue = nowPlayingQueue; @@ -474,6 +474,7 @@ namespace Emby.Server.Implementations.Session private void RemoveNowPlayingItem(SessionInfo session) { session.NowPlayingItem = null; + session.FullNowPlayingItem = null; session.PlayState = new PlayerStateInfo(); if (!string.IsNullOrEmpty(session.DeviceId)) diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index d4606abd2..6a26e92e1 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -5,6 +5,8 @@ using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Net.WebSocketMessages.Outbound; using MediaBrowser.Controller.Session; @@ -44,6 +46,7 @@ namespace Emby.Server.Implementations.Session private readonly Lock _webSocketsLock = new(); private readonly ISessionManager _sessionManager; + private readonly IUserManager _userManager; private readonly ILogger<SessionWebSocketListener> _logger; private readonly ILoggerFactory _loggerFactory; @@ -57,14 +60,17 @@ namespace Emby.Server.Implementations.Session /// </summary> /// <param name="logger">The logger.</param> /// <param name="sessionManager">The session manager.</param> + /// <param name="userManager">The user manager.</param> /// <param name="loggerFactory">The logger factory.</param> public SessionWebSocketListener( ILogger<SessionWebSocketListener> logger, ISessionManager sessionManager, + IUserManager userManager, ILoggerFactory loggerFactory) { _logger = logger; _sessionManager = sessionManager; + _userManager = userManager; _loggerFactory = loggerFactory; _keepAlive = new System.Timers.Timer(TimeSpan.FromSeconds(WebSocketLostTimeout * IntervalFactor)) { @@ -107,33 +113,9 @@ namespace Emby.Server.Implementations.Session /// <inheritdoc /> public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection, HttpContext httpContext) { - var session = await GetSession(httpContext, connection.RemoteEndPoint?.ToString()).ConfigureAwait(false); - if (session is not null) - { - EnsureController(session, connection); - await KeepAliveWebSocket(connection).ConfigureAwait(false); - } - else - { - _logger.LogWarning("Unable to determine session based on query string: {0}", httpContext.Request.QueryString); - } - } - - private async Task<SessionInfo?> GetSession(HttpContext httpContext, string? remoteEndpoint) - { - if (!httpContext.User.Identity?.IsAuthenticated ?? false) - { - return null; - } - - var deviceId = httpContext.User.GetDeviceId(); - if (httpContext.Request.Query.TryGetValue("deviceId", out var queryDeviceId)) - { - deviceId = queryDeviceId; - } - - return await _sessionManager.GetSessionByAuthenticationToken(httpContext.User.GetToken(), deviceId, remoteEndpoint) - .ConfigureAwait(false); + var session = await RequestHelpers.GetSession(_sessionManager, _userManager, httpContext).ConfigureAwait(false); + EnsureController(session, connection); + await KeepAliveWebSocket(connection).ConfigureAwait(false); } private void EnsureController(SessionInfo session, IWebSocketConnection connection) diff --git a/Emby.Server.Implementations/Sorting/StartDateComparer.cs b/Emby.Server.Implementations/Sorting/StartDateComparer.cs index e0b438ef1..861ca2d3a 100644 --- a/Emby.Server.Implementations/Sorting/StartDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/StartDateComparer.cs @@ -5,7 +5,6 @@ using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Sorting; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { diff --git a/Emby.Server.Implementations/SystemManager.cs b/Emby.Server.Implementations/SystemManager.cs index 92b59b23c..d140426dd 100644 --- a/Emby.Server.Implementations/SystemManager.cs +++ b/Emby.Server.Implementations/SystemManager.cs @@ -85,7 +85,10 @@ public class SystemManager : ISystemManager /// <inheritdoc/> public SystemStorageInfo GetSystemStorageInfo() { - var virtualFolderInfos = _libraryManager.GetVirtualFolders().Select(e => new LibraryStorageInfo() + var virtualFolderInfos = _libraryManager + .GetVirtualFolders() + .Where(e => !string.IsNullOrWhiteSpace(e.ItemId)) // this should not be null but for some users it is. + .Select(e => new LibraryStorageInfo() { Id = Guid.Parse(e.ItemId), Name = e.Name, diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 4cac8ed67..2614fe995 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -46,6 +46,7 @@ public class DynamicHlsController : BaseJellyfinApiController private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0); private readonly Version _minFFmpegX265BframeInFmp4 = new Version(7, 0, 1); + private readonly Version _minFFmpegHlsSegmentOptions = new Version(5, 0); private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; @@ -1606,6 +1607,7 @@ public class DynamicHlsController : BaseJellyfinApiController var segmentFormat = string.Empty; var segmentContainer = outputExtension.TrimStart('.'); var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer); + var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0"; if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase)) { @@ -1621,6 +1623,11 @@ public class DynamicHlsController : BaseJellyfinApiController false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"" }; + var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions; + + // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT + hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont"; + segmentFormat = "fmp4" + outputFmp4HeaderArg; } else @@ -1642,8 +1649,6 @@ public class DynamicHlsController : BaseJellyfinApiController Path.GetFileNameWithoutExtension(outputPath)); } - var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0"; - return string.Format( CultureInfo.InvariantCulture, "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{11}\" {12} -y \"{13}\"", diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 50eeaeac6..e1d9b6bba 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -158,7 +158,10 @@ public class ItemUpdateController : BaseJellyfinApiController ParentalRatingOptions = _localizationManager.GetParentalRatings().ToList(), ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(), Countries = _localizationManager.GetCountries().ToArray(), - Cultures = _localizationManager.GetCultures().ToArray() + Cultures = _localizationManager.GetCultures() + .DistinctBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase) + .OrderBy(c => c.DisplayName) + .ToArray() }; if (!item.IsVirtualItem diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs index bbce5a9e1..dd8f935dc 100644 --- a/Jellyfin.Api/Controllers/LocalizationController.cs +++ b/Jellyfin.Api/Controllers/LocalizationController.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Linq; using MediaBrowser.Common.Api; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -34,7 +36,14 @@ public class LocalizationController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<IEnumerable<CultureDto>> GetCultures() { - return Ok(_localization.GetCultures()); + var allCultures = _localization.GetCultures(); + + var distinctCultures = allCultures + .DistinctBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase) + .OrderBy(c => c.DisplayName) + .AsEnumerable(); + + return Ok(distinctCultures); } /// <summary> diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 09f20558f..3bb68553d 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; @@ -131,16 +132,16 @@ public class StartupController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto) { + ArgumentNullException.ThrowIfNull(startupUserDto.Name); + _userManager.ThrowIfInvalidUsername(startupUserDto.Name); + var user = _userManager.Users.First(); if (string.IsNullOrWhiteSpace(startupUserDto.Password)) { return BadRequest("Password must not be empty"); } - if (startupUserDto.Name is not null) - { - user.Username = startupUserDto.Name; - } + user.Username = startupUserDto.Name; await _userManager.UpdateUserAsync(user).ConfigureAwait(false); diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 0690f0c8d..4034a8088 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -32,17 +32,67 @@ public static class FileStreamResponseHelpers HttpContext httpContext, CancellationToken cancellationToken = default) { + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri(state.MediaPath)); + + // Forward User-Agent if provided if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) { - httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent); + // Clear default and add specific one if exists, otherwise HttpClient default might be used + requestMessage.Headers.UserAgent.Clear(); + requestMessage.Headers.TryAddWithoutValidation(HeaderNames.UserAgent, useragent); + } + + // Forward Range header if present in the client request + if (httpContext.Request.Headers.TryGetValue(HeaderNames.Range, out var rangeValue)) + { + var rangeString = rangeValue.ToString(); + if (!string.IsNullOrEmpty(rangeString)) + { + requestMessage.Headers.Range = System.Net.Http.Headers.RangeHeaderValue.Parse(rangeString); + } } - // Can't dispose the response as it's required up the call chain. - var response = await httpClient.GetAsync(new Uri(state.MediaPath), HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain; + // Send the request to the upstream server + // Use ResponseHeadersRead to avoid downloading the whole content immediately + var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; + // Check if the upstream server supports range requests and acted upon our Range header + bool upstreamSupportsRange = response.StatusCode == System.Net.HttpStatusCode.PartialContent; + string acceptRangesValue = "none"; + if (response.Headers.TryGetValues(HeaderNames.AcceptRanges, out var acceptRangesHeaders)) + { + // Prefer upstream server's Accept-Ranges header if available + acceptRangesValue = string.Join(", ", acceptRangesHeaders); + upstreamSupportsRange |= acceptRangesValue.Contains("bytes", StringComparison.OrdinalIgnoreCase); + } + else if (upstreamSupportsRange) // If we got 206 but no Accept-Ranges header, assume bytes + { + acceptRangesValue = "bytes"; + } + + // Set Accept-Ranges header for the client based on upstream support + httpContext.Response.Headers[HeaderNames.AcceptRanges] = acceptRangesValue; + + // Set Content-Range header if upstream provided it (implies partial content) + if (response.Content.Headers.ContentRange is not null) + { + httpContext.Response.Headers[HeaderNames.ContentRange] = response.Content.Headers.ContentRange.ToString(); + } + + // Set Content-Length header. For partial content, this is the length of the partial segment. + if (response.Content.Headers.ContentLength.HasValue) + { + httpContext.Response.ContentLength = response.Content.Headers.ContentLength.Value; + } + + // Set Content-Type header + var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Application.Octet; // Use a more generic default + + // Set the status code for the client response (e.g., 200 OK or 206 Partial Content) + httpContext.Response.StatusCode = (int)response.StatusCode; + // Return the stream from the upstream server + // IMPORTANT: Do not dispose the response stream here, FileStreamResult will handle it. return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType); } diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index e10e940f2..5072f902d 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -111,7 +111,16 @@ public static class RequestHelpers return user.EnableUserPreferenceAccess; } - internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null) + /// <summary> + /// Get the session based on http request. + /// </summary> + /// <param name="sessionManager">The session manager.</param> + /// <param name="userManager">The user manager.</param> + /// <param name="httpContext">The http context.</param> + /// <param name="userId">The optional userid.</param> + /// <returns>The session.</returns> + /// <exception cref="ResourceNotFoundException">Session not found.</exception> + public static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null) { userId ??= httpContext.User.GetUserId(); User? user = null; diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs index 63c80634f..932f9d625 100644 --- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs @@ -139,7 +139,7 @@ public static class ServiceCollectionExtensions serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) => { var provider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>(); - provider.Initialise(opt); + provider.Initialise(opt, efCoreConfiguration); var lockingBehavior = serviceProvider.GetRequiredService<IEntityFrameworkCoreLockingBehavior>(); lockingBehavior.Initialise(opt); }); diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index e266d5a3b..74d99455d 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.1.0"); + private readonly Version _backupEngineVersion = Version.Parse("0.2.0"); /// <summary> /// Initializes a new instance of the <see cref="BackupService"/> class. @@ -120,26 +120,29 @@ public class BackupService : IBackupService void CopyDirectory(string source, string target) { - source = Path.GetFullPath(source); - Directory.CreateDirectory(source); - + var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar); + var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar; foreach (var item in zipArchive.Entries) { - var sanitizedSourcePath = Path.GetFullPath(item.FullName.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar); - if (!sanitizedSourcePath.StartsWith(target, StringComparison.Ordinal)) + var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName)); + var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName))); + + if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal) + || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)) { continue; } - var targetPath = Path.Combine(source, sanitizedSourcePath[target.Length..].Trim('/')); _logger.LogInformation("Restore and override {File}", targetPath); - item.ExtractToFile(targetPath); + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + item.ExtractToFile(targetPath, overwrite: true); } } - CopyDirectory(_applicationPaths.ConfigurationDirectoryPath, "Config/"); - CopyDirectory(_applicationPaths.DataPath, "Data/"); - CopyDirectory(_applicationPaths.RootFolderPath, "Root/"); + CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath); + CopyDirectory("Data", _applicationPaths.DataPath); + CopyDirectory("Root", _applicationPaths.RootFolderPath); if (manifest.Options.Database) { @@ -148,7 +151,7 @@ public class BackupService : IBackupService await using (dbContext.ConfigureAwait(false)) { // restore migration history manually - var historyEntry = zipArchive.GetEntry($"Database\\{nameof(HistoryRow)}.json"); + var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(HistoryRow)}.json"))); if (historyEntry is null) { _logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation"); @@ -165,6 +168,13 @@ public class BackupService : IBackupService var historyRepository = dbContext.GetService<IHistoryRepository>(); await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false); + + foreach (var item in await historyRepository.GetAppliedMigrationsAsync(CancellationToken.None).ConfigureAwait(false)) + { + var insertScript = historyRepository.GetDeleteScript(item.MigrationId); + await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false); + } + foreach (var item in historyEntries) { var insertScript = historyRepository.GetInsertScript(item); @@ -186,7 +196,7 @@ public class BackupService : IBackupService { _logger.LogInformation("Read backup of {Table}", entityType.Type.Name); - var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json"); + var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json"))); if (zipEntry is null) { _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name); @@ -198,7 +208,7 @@ public class BackupService : IBackupService { _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name); var records = 0; - await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false)!) + await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false)) { var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]); if (entity is null) @@ -281,7 +291,7 @@ public class BackupService : IBackupService await using (dbContext.ConfigureAwait(false)) { dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - static IAsyncEnumerable<object> GetValues(IQueryable dbSet, Type type) + static IAsyncEnumerable<object> GetValues(IQueryable dbSet) { var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!; var enumerable = method.Invoke(dbSet, null)!; @@ -292,12 +302,12 @@ public class BackupService : IBackupService var historyRepository = dbContext.GetService<IHistoryRepository>(); var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); - ICollection<(Type Type, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [ + ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [ .. typeof(JellyfinDbContext) .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable))) - .Select(e => (Type: e.PropertyType, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!, e.PropertyType)))), - (Type: typeof(HistoryRow), ValueFactory: new Func<IAsyncEnumerable<object>>(() => migrations.ToAsyncEnumerable())) + .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))), + (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable()) ]; manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray(); var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false); @@ -308,8 +318,8 @@ public class BackupService : IBackupService foreach (var entityType in entityTypes) { - _logger.LogInformation("Begin backup of entity {Table}", entityType.Type.Name); - var zipEntry = zipArchive.CreateEntry($"Database\\{entityType.Type.Name}.json"); + _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName); + var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json"))); var entities = 0; var zipEntryStream = zipEntry.Open(); await using (zipEntryStream.ConfigureAwait(false)) @@ -347,7 +357,7 @@ public class BackupService : IBackupService foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly) .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly))) { - zipArchive.CreateEntryFromFile(item, Path.Combine("Config", Path.GetFileName(item))); + zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))); } void CopyDirectory(string source, string target, string filter = "*") @@ -361,7 +371,7 @@ public class BackupService : IBackupService foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories)) { - zipArchive.CreateEntryFromFile(item, Path.Combine(target, item[..source.Length].Trim('\\'))); + zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item)))); } } @@ -509,4 +519,14 @@ public class BackupService : IBackupService Database = options.Database }; } + + /// <summary> + /// Windows is able to handle '/' as a path seperator in zip files + /// but linux isn't able to handle '\' as a path seperator in zip files, + /// So normalize to '/'. + /// </summary> + /// <param name="path">The path to normalize.</param> + /// <returns>The normalized path. </returns> + private static string NormalizePathSeparator(string path) + => path.Replace('\\', '/'); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 3efcb6dd3..d59eba690 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -14,6 +14,7 @@ using System.Reflection; using System.Text; using System.Text.Json; using System.Threading; +using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; @@ -54,6 +55,11 @@ public sealed class BaseItemRepository : IItemRepository { /// <summary> + /// Gets the placeholder id for UserData detached items. + /// </summary> + public static readonly Guid PlaceholderId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + /// <summary> /// This holds all the types in the running assemblies /// so that we can de-serialize properly when we don't have strong types. /// </summary> @@ -95,13 +101,35 @@ public sealed class BaseItemRepository /// <inheritdoc /> public void DeleteItem(Guid id) { - if (id.IsEmpty()) + if (id.IsEmpty() || id.Equals(PlaceholderId)) { - throw new ArgumentException("Guid can't be empty", nameof(id)); + throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(id)); } using var context = _dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); + + var date = (DateTime?)DateTime.UtcNow; + + // 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), + placeholder => new { placeholder.UserId, placeholder.CustomDataKey }, + userData => new { userData.UserId, userData.CustomDataKey }, + (placeholder, userData) => placeholder) + .Where(e => e.ItemId == PlaceholderId) + .ExecuteDelete(); + + // Detach all user watch data + context.UserData.Where(e => e.ItemId == id) + .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(); @@ -144,7 +172,7 @@ public sealed class BaseItemRepository PrepareFilterQuery(filter); using var context = _dbProvider.CreateDbContext(); - return ApplyQueryFilter(context.BaseItems.AsNoTracking(), context, filter).Select(e => e.Id).ToArray(); + return ApplyQueryFilter(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, filter).Select(e => e.Id).ToArray(); } /// <inheritdoc /> @@ -242,7 +270,7 @@ public sealed class BaseItemRepository dbQuery = ApplyGroupingFilter(dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); - result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray(); + result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); result.StartIndex = filter.StartIndex ?? 0; return result; } @@ -261,7 +289,7 @@ public sealed class BaseItemRepository dbQuery = ApplyGroupingFilter(dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); - return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray(); + return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); } /// <inheritdoc/> @@ -303,7 +331,7 @@ public sealed class BaseItemRepository mainquery = ApplyGroupingFilter(mainquery, filter); mainquery = ApplyQueryPaging(mainquery, filter); - return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray(); + return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); } /// <inheritdoc /> @@ -319,7 +347,7 @@ public sealed class BaseItemRepository .Where(i => filter.TopParentIds.Contains(i.TopParentId!.Value)) .Where(i => i.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]) .Join( - context.UserData.AsNoTracking(), + context.UserData.AsNoTracking().Where(e => e.ItemId != EF.Constant(PlaceholderId)), i => new { UserId = filter.User.Id, ItemId = i.Id }, u => new { UserId = u.UserId, ItemId = u.ItemId }, (entity, data) => new { Item = entity, UserData = data }) @@ -454,6 +482,13 @@ public sealed class BaseItemRepository var images = item.ImageInfos.Select(e => Map(item.Id, e)); using var context = _dbProvider.CreateDbContext(); + + if (!context.BaseItems.Any(bi => bi.Id == item.Id)) + { + _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem"); + return; + } + context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete(); context.BaseItemImageInfos.AddRange(images); context.SaveChanges(); @@ -472,7 +507,7 @@ public sealed class BaseItemRepository cancellationToken.ThrowIfCancellationRequested(); var tuples = new List<(BaseItemDto Item, List<Guid>? AncestorIds, BaseItemDto TopParent, IEnumerable<string> UserDataKey, List<string> InheritedTags)>(); - foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last())) + foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()).Where(e => e.Id != PlaceholderId)) { var ancestorIds = item.SupportsAncestors ? item.GetAncestorIds().Distinct().ToList() : @@ -491,6 +526,7 @@ public sealed class BaseItemRepository var ids = tuples.Select(f => f.Item.Id).ToArray(); var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray(); + var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray(); foreach (var item in tuples) { @@ -511,8 +547,21 @@ public sealed class BaseItemRepository context.SaveChanges(); + foreach (var item in newItems) + { + // reattach old userData entries + var userKeys = item.UserDataKey.ToArray(); + var retentionDate = (DateTime?)null; + context.UserData + .Where(e => e.ItemId == PlaceholderId) + .Where(e => userKeys.Contains(e.CustomDataKey)) + .ExecuteUpdate(e => e + .SetProperty(f => f.ItemId, item.Item.Id) + .SetProperty(f => f.RetentionDate, retentionDate)); + } + var itemValueMaps = tuples - .Select(e => (Item: e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags))) + .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags))) .ToArray(); var allListedItemValues = itemValueMaps .SelectMany(f => f.Values) @@ -539,7 +588,7 @@ public sealed class BaseItemRepository var itemValuesStore = existingValues.Concat(missingItemValues).ToArray(); var valueMap = itemValueMaps - .Select(f => (Item: f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).ToArray())) + .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray())) .ToArray(); var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList(); @@ -627,7 +676,7 @@ public sealed class BaseItemRepository return null; } - return DeserialiseBaseItem(item); + return DeserializeBaseItem(item); } /// <summary> @@ -673,12 +722,12 @@ public sealed class BaseItemRepository dto.TotalBitrate = entity.TotalBitrate; dto.ExternalId = entity.ExternalId; dto.Size = entity.Size; - dto.Genres = entity.Genres?.Split('|') ?? []; - dto.DateCreated = entity.DateCreated.GetValueOrDefault(); - dto.DateModified = entity.DateModified.GetValueOrDefault(); + dto.Genres = string.IsNullOrWhiteSpace(entity.Genres) ? [] : entity.Genres.Split('|'); + dto.DateCreated = entity.DateCreated ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); + dto.DateModified = entity.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); dto.ChannelId = entity.ChannelId ?? Guid.Empty; - dto.DateLastRefreshed = entity.DateLastRefreshed.GetValueOrDefault(); - dto.DateLastSaved = entity.DateLastSaved.GetValueOrDefault(); + dto.DateLastRefreshed = entity.DateLastRefreshed ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); + dto.DateLastSaved = entity.DateLastSaved ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); 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(); @@ -705,7 +754,7 @@ public sealed class BaseItemRepository dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray(); dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? []; dto.Studios = entity.Studios?.Split('|') ?? []; - dto.Tags = entity.Tags?.Split('|') ?? []; + dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|'); if (dto is IHasProgramAttributes hasProgramAttributes) { @@ -779,7 +828,7 @@ public sealed class BaseItemRepository if (dto is Folder folder) { - folder.DateLastMediaAdded = entity.DateLastMediaAdded; + folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } return dto; @@ -839,11 +888,11 @@ public sealed class BaseItemRepository entity.ExternalId = dto.ExternalId; entity.Size = dto.Size; entity.Genres = string.Join('|', dto.Genres); - entity.DateCreated = dto.DateCreated; - entity.DateModified = dto.DateModified; + entity.DateCreated = dto.DateCreated == DateTime.MinValue ? null : dto.DateCreated; + entity.DateModified = dto.DateModified == DateTime.MinValue ? null : dto.DateModified; entity.ChannelId = dto.ChannelId; - entity.DateLastRefreshed = dto.DateLastRefreshed; - entity.DateLastSaved = dto.DateLastSaved; + entity.DateLastRefreshed = dto.DateLastRefreshed == DateTime.MinValue ? null : dto.DateLastRefreshed; + entity.DateLastSaved = dto.DateLastSaved == DateTime.MinValue ? null : dto.DateLastSaved; entity.OwnerId = dto.OwnerId.ToString(); entity.Width = dto.Width; entity.Height = dto.Height; @@ -953,7 +1002,7 @@ public sealed class BaseItemRepository if (dto is Folder folder) { - entity.DateLastMediaAdded = folder.DateLastMediaAdded; + entity.DateLastMediaAdded = folder.DateLastMediaAdded == DateTime.MinValue ? null : folder.DateLastMediaAdded; entity.IsFolder = folder.IsFolder; } @@ -989,7 +1038,7 @@ public sealed class BaseItemRepository return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null; } - private BaseItemDto DeserialiseBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false) + private BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false) { ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity)); if (_serverConfigurationManager?.Configuration is null) @@ -998,7 +1047,7 @@ public sealed class BaseItemRepository } var typeToSerialise = GetType(baseItemEntity.Type); - return BaseItemRepository.DeserialiseBaseItem( + return BaseItemRepository.DeserializeBaseItem( baseItemEntity, _logger, _appHost, @@ -1006,7 +1055,7 @@ public sealed class BaseItemRepository } /// <summary> - /// Deserialises a BaseItemEntity and sets all properties. + /// Deserializes a BaseItemEntity and sets all properties. /// </summary> /// <param name="baseItemEntity">The DB entity.</param> /// <param name="logger">Logger.</param> @@ -1014,9 +1063,9 @@ public sealed class BaseItemRepository /// <param name="skipDeserialization">If only mapping should be processed.</param> /// <returns>A mapped BaseItem.</returns> /// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception> - public static BaseItemDto DeserialiseBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) + public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) { - var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialise unknown type."); + var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize unknown type."); BaseItemDto? dto = null; if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization) { @@ -1032,7 +1081,7 @@ public sealed class BaseItemRepository if (dto is null) { - dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialise unknown type."); + dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialize unknown type."); } return Map(baseItemEntity, dto, appHost); @@ -1049,7 +1098,7 @@ public sealed class BaseItemRepository using var context = _dbProvider.CreateDbContext(); - var innerQueryFilter = TranslateQuery(context.BaseItems, context, new InternalItemsQuery(filter.User) + var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User) { ExcludeItemTypes = filter.ExcludeItemTypes, IncludeItemTypes = filter.IncludeItemTypes, @@ -1138,7 +1187,7 @@ public sealed class BaseItemRepository IsPlayed = filter.IsPlayed }; - itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking(), context, typeSubQuery) + itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; @@ -1178,7 +1227,7 @@ public sealed class BaseItemRepository .Where(e => e is not null) .Select(e => { - return (DeserialiseBaseItem(e.item, filter.SkipDeserialization), e.itemCount); + return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount); }) ]; } @@ -1193,7 +1242,7 @@ public sealed class BaseItemRepository .Where(e => e is not null) .Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e => { - return (DeserialiseBaseItem(e, filter.SkipDeserialization), null); + return (DeserializeBaseItem(e, filter.SkipDeserialization), null); }) ]; } @@ -1274,7 +1323,7 @@ public sealed class BaseItemRepository { Path = appHost?.ExpandVirtualPath(e.Path) ?? e.Path, BlurHash = e.Blurhash is null ? null : Encoding.UTF8.GetString(e.Blurhash), - DateModified = e.DateModified, + DateModified = e.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc), Height = e.Height, Width = e.Width, Type = (ImageType)e.ImageType @@ -1814,7 +1863,7 @@ public sealed class BaseItemRepository // We should probably figure this out for all folders, but for right now, this is the only place where we need it if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series) { - baseQuery = baseQuery.Where(e => context.BaseItems + baseQuery = baseQuery.Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)) .Where(e => e.IsFolder == false && e.IsVirtualItem == false) .Where(f => f.UserData!.FirstOrDefault(e => e.UserId == filter.User!.Id && e.Played)!.Played) .Any(f => f.SeriesPresentationUniqueKey == e.PresentationUniqueKey) == filter.IsPlayed); @@ -2064,7 +2113,7 @@ public sealed class BaseItemRepository if (filter.HasDeadParentId.HasValue && filter.HasDeadParentId.Value) { baseQuery = baseQuery - .Where(e => e.ParentId.HasValue && !context.BaseItems.Any(f => f.Id == e.ParentId.Value)); + .Where(e => e.ParentId.HasValue && !context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).Any(f => f.Id == e.ParentId.Value)); } if (filter.IsDeadArtist.HasValue && filter.IsDeadArtist.Value) @@ -2145,17 +2194,19 @@ public sealed class BaseItemRepository if (filter.ExcludeItemIds.Length > 0) { baseQuery = baseQuery - .Where(e => !filter.ItemIds.Contains(e.Id)); + .Where(e => !filter.ExcludeItemIds.Contains(e.Id)); } if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0) { - baseQuery = baseQuery.Where(e => !e.Provider!.All(f => !filter.ExcludeProviderIds.All(w => f.ProviderId == w.Key && f.ProviderValue == w.Value))); + var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray(); + baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f))); } if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => !filter.HasAnyProviderId.Any(w => f.ProviderId == w.Key && f.ProviderValue == w.Value))); + 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))); } if (filter.HasImdbId.HasValue) @@ -2197,7 +2248,7 @@ public sealed class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey)) { baseQuery = baseQuery - .Where(e => context.BaseItems.Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.Children!.Any(w => w.ItemId == e.Id))); + .Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.Children!.Any(w => w.ItemId == e.Id))); } if (!string.IsNullOrWhiteSpace(filter.SeriesPresentationUniqueKey)) @@ -2209,8 +2260,8 @@ public sealed class BaseItemRepository if (filter.ExcludeInheritedTags.Length > 0) { baseQuery = baseQuery - .Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags) - .Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))); + .Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags) + .Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))); } if (filter.IncludeInheritedTags.Length > 0) @@ -2220,10 +2271,10 @@ public sealed class BaseItemRepository if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode) { baseQuery = baseQuery - .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags) + .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags) .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) || - (e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value)!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags) + (e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value && (w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags)) .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)))); } @@ -2231,17 +2282,16 @@ public sealed class BaseItemRepository else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist) { baseQuery = baseQuery - .Where(e => - e.Parents! - .Any(f => - f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue)) - || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); + .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags) + .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) + || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")); // d ^^ this is stupid it hate this. } else { baseQuery = baseQuery - .Where(e => e.Parents!.Any(f => f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue)))); + .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags) + .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))); } } @@ -2324,4 +2374,14 @@ public sealed class BaseItemRepository return baseQuery; } + + /// <inheritdoc/> + public async Task<bool> ItemExistsAsync(Guid id) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + return await dbContext.BaseItems.AnyAsync(f => f.Id == id).ConfigureAwait(false); + } + } } diff --git a/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs b/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs index 3ae6dbd70..e75dda439 100644 --- a/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs @@ -25,8 +25,16 @@ public class MediaAttachmentRepository(IDbContextFactory<JellyfinDbContext> dbPr { using var context = dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); + + // Users may replace a media with a version that includes attachments to one without them. + // So when saving attachments is triggered by a library scan, we always unconditionally + // clear the old ones, and then add the new ones if given. context.AttachmentStreamInfos.Where(e => e.ItemId.Equals(id)).ExecuteDelete(); - context.AttachmentStreamInfos.AddRange(attachments.Select(e => Map(e, id))); + if (attachments.Any()) + { + context.AttachmentStreamInfos.AddRange(attachments.Select(e => Map(e, id))); + } + context.SaveChanges(); transaction.Commit(); } diff --git a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs index 28b6890b0..97c9d79f5 100644 --- a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs +++ b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs @@ -51,7 +51,7 @@ public class MediaSegmentManager : IMediaSegmentManager } /// <inheritdoc/> - public async Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool overwrite, CancellationToken cancellationToken) + public async Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken) { var providers = _segmentProviders .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) @@ -70,18 +70,13 @@ public class MediaSegmentManager : IMediaSegmentManager using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - if (!overwrite && (await db.MediaSegments.AnyAsync(e => e.ItemId.Equals(baseItem.Id), cancellationToken).ConfigureAwait(false))) - { - _logger.LogDebug("Skip {MediaPath} as it already contains media segments", baseItem.Path); - return; - } - _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count); - await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); - - // no need to recreate the request object every time. - var requestItem = new MediaSegmentGenerationRequest() { ItemId = baseItem.Id }; + 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) { @@ -91,15 +86,56 @@ public class MediaSegmentManager : IMediaSegmentManager continue; } + 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() + }; + try { var segments = await provider.GetMediaSegments(requestItem, cancellationToken) .ConfigureAwait(false); - if (segments.Count == 0) + + 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()) { _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); diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 3dfb14d71..4f944c87d 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -744,7 +744,8 @@ namespace Jellyfin.Server.Implementations.Users _users[user.Id] = user; } - internal static void ThrowIfInvalidUsername(string name) + /// <inheritdoc/> + public void ThrowIfInvalidUsername(string name) { if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name)) { diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 09a4e2ed3..08c1a5065 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -116,26 +116,7 @@ namespace Jellyfin.Server.Extensions .AddTransient<ICorsPolicyProvider, CorsPolicyProvider>() .Configure<ForwardedHeadersOptions>(options => { - // https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs - // Enable debug logging on Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware to help investigate issues. - - if (config.KnownProxies.Length == 0) - { - options.ForwardedHeaders = ForwardedHeaders.None; - options.KnownNetworks.Clear(); - options.KnownProxies.Clear(); - } - else - { - options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; - AddProxyAddresses(config, config.KnownProxies, options); - } - - // Only set forward limit if we have some known proxies or some known networks. - if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0) - { - options.ForwardLimit = null; - } + ConfigureForwardHeaders(config, options); }) .AddMvc(opts => { @@ -183,6 +164,30 @@ namespace Jellyfin.Server.Extensions return mvcBuilder.AddControllersAsServices(); } + internal static void ConfigureForwardHeaders(NetworkConfiguration config, ForwardedHeadersOptions options) + { + // https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs + // Enable debug logging on Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware to help investigate issues. + + if (config.KnownProxies.Length == 0) + { + options.ForwardedHeaders = ForwardedHeaders.None; + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + } + else + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; + AddProxyAddresses(config, config.KnownProxies, options); + } + + // Only set forward limit if we have some known proxies or some known networks. + if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0) + { + options.ForwardLimit = null; + } + } + /// <summary> /// Adds Swagger to the service collection. /// </summary> @@ -248,7 +253,7 @@ namespace Jellyfin.Server.Extensions c.AddSwaggerTypeMappings(); c.SchemaFilter<IgnoreEnumSchemaFilter>(); - c.OperationFilter<RetryOnTemporarlyUnavailableFilter>(); + c.OperationFilter<RetryOnTemporarilyUnavailableFilter>(); c.OperationFilter<SecurityRequirementsOperationFilter>(); c.OperationFilter<FileResponseFilter>(); c.OperationFilter<FileRequestFilter>(); diff --git a/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs index 74470eda0..fef5577a1 100644 --- a/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs +++ b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs @@ -6,13 +6,13 @@ using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Filters; -internal class RetryOnTemporarlyUnavailableFilter : IOperationFilter +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 temporarly not available.", + Description = "The server is currently starting or is temporarily not available.", Headers = new Dictionary<string, OpenApiHeader>() { { diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 14c4285fe..df630922a 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -53,6 +53,7 @@ <PackageReference Include="prometheus-net.AspNetCore" /> <PackageReference Include="Serilog.AspNetCore" /> <PackageReference Include="Serilog.Enrichers.Thread" /> + <PackageReference Include="Serilog.Expressions" /> <PackageReference Include="Serilog.Settings.Configuration" /> <PackageReference Include="Serilog.Sinks.Async" /> <PackageReference Include="Serilog.Sinks.Console" /> diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index 5331b43e3..fe191916c 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -17,6 +17,7 @@ using MediaBrowser.Model.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations; @@ -47,7 +48,7 @@ internal class JellyfinMigrationService public JellyfinMigrationService( IDbContextFactory<JellyfinDbContext> dbContextFactory, ILoggerFactory loggerFactory, - IStartupLogger startupLogger, + IStartupLogger<JellyfinMigrationService> startupLogger, IApplicationPaths applicationPaths, IBackupService? backupService = null, IJellyfinDatabaseProvider? jellyfinDatabaseProvider = null) @@ -105,6 +106,13 @@ internal class JellyfinMigrationService var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { + var databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator + ?? throw new InvalidOperationException("Jellyfin does only support relational databases."); + if (!await databaseCreator.ExistsAsync().ConfigureAwait(false)) + { + await databaseCreator.CreateAsync().ConfigureAwait(false); + } + var historyRepository = dbContext.GetService<IHistoryRepository>(); await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false); diff --git a/Jellyfin.Server/Migrations/Routines/FixDates.cs b/Jellyfin.Server/Migrations/Routines/FixDates.cs new file mode 100644 index 000000000..f112502b9 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/FixDates.cs @@ -0,0 +1,168 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to fix dates saved in the database to always be UTC. +/// </summary> +[JellyfinMigration("2025-06-20T18:00:00", nameof(FixDates))] +public class FixDates : IAsyncMigrationRoutine +{ + private const int PageSize = 5000; + + private readonly ILogger _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="FixDates"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="startupLogger">The startup logger for Startup UI integration.</param> + /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param> + public FixDates( + ILogger<FixDates> logger, + IStartupLogger<FixDates> startupLogger, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = startupLogger.With(logger); + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + 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); + } + } + + private async Task FixBaseItemsAsync(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken) + { + int itemCount = 0; + + var baseQuery = context.BaseItems.OrderBy(e => e.Id); + var records = baseQuery.Count(); + _logger.LogInformation("Fixing dates for {Count} BaseItems.", records); + + sw.Start(); + await foreach (var result in context.BaseItems.OrderBy(e => e.Id) + .WithPartitionProgress( + (partition) => + _logger.LogInformation( + "Processing BaseItems batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}", + partition + 1, + Math.Min((partition + 1) * PageSize, records), + records, + sw.Elapsed)) + .PartitionEagerAsync(PageSize, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + result.DateCreated = ToUniversalTime(result.DateCreated); + result.DateLastMediaAdded = ToUniversalTime(result.DateLastMediaAdded); + result.DateLastRefreshed = ToUniversalTime(result.DateLastRefreshed); + result.DateLastSaved = ToUniversalTime(result.DateLastSaved); + result.DateModified = ToUniversalTime(result.DateModified); + itemCount++; + } + + var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation("BaseItems: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed); + } + + private async Task FixChaptersAsync(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken) + { + int itemCount = 0; + + var baseQuery = context.Chapters; + var records = baseQuery.Count(); + _logger.LogInformation("Fixing dates for {Count} Chapters.", records); + + sw.Start(); + await foreach (var result in context.Chapters.OrderBy(e => e.ItemId) + .WithPartitionProgress( + (partition) => + _logger.LogInformation( + "Processing Chapter batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}", + partition + 1, + Math.Min((partition + 1) * PageSize, records), + records, + sw.Elapsed)) + .PartitionEagerAsync(PageSize, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + result.ImageDateModified = ToUniversalTime(result.ImageDateModified, true); + itemCount++; + } + + var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Chapters: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed); + } + + private async Task FixBaseItemImageInfos(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken) + { + int itemCount = 0; + + var baseQuery = context.BaseItemImageInfos; + var records = baseQuery.Count(); + _logger.LogInformation("Fixing dates for {Count} BaseItemImageInfos.", records); + + sw.Start(); + await foreach (var result in context.BaseItemImageInfos.OrderBy(e => e.Id) + .WithPartitionProgress( + (partition) => + _logger.LogInformation( + "Processing BaseItemImageInfos batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}", + partition + 1, + Math.Min((partition + 1) * PageSize, records), + records, + sw.Elapsed)) + .PartitionEagerAsync(PageSize, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + result.DateModified = ToUniversalTime(result.DateModified); + itemCount++; + } + + var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation("BaseItemImageInfos: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed); + } + + private DateTime? ToUniversalTime(DateTime? dateTime, bool isUTC = false) + { + if (dateTime is null) + { + return null; + } + + if (dateTime.Value.Year == 1 && dateTime.Value.Month == 1 && dateTime.Value.Day == 1) + { + return null; + } + + if (dateTime.Value.Kind == DateTimeKind.Utc || isUTC) + { + return dateTime.Value; + } + + return dateTime.Value.ToUniversalTime(); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs index 033045e63..c199ee4d6 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs @@ -35,7 +35,7 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> /// <param name="dbProvider">The EFCore db factory.</param> public MigrateKeyframeData( - IStartupLogger startupLogger, + IStartupLogger<MigrateKeyframeData> startupLogger, IApplicationPaths appPaths, IDbContextFactory<JellyfinDbContext> dbProvider) { diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs index 521655a4f..e04a2737a 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs @@ -48,7 +48,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine /// <param name="paths">The server application paths.</param> /// <param name="jellyfinDatabaseProvider">The database provider for special access.</param> public MigrateLibraryDb( - IStartupLogger startupLogger, + IStartupLogger<MigrateLibraryDb> startupLogger, IDbContextFactory<JellyfinDbContext> provider, IServerApplicationPaths paths, IJellyfinDatabaseProvider jellyfinDatabaseProvider) @@ -90,11 +90,14 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine operation.JellyfinDbContext.AncestorIds.ExecuteDelete(); } + // notify the other migration to just silently abort because the fix has been applied here already. + ReseedFolderFlag.RerunGuardFlag = true; + var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>(); connection.Open(); var baseItemIds = new HashSet<Guid>(); - using (var operation = GetPreparedDbContext("moving TypedBaseItem")) + using (var operation = GetPreparedDbContext("Moving TypedBaseItem")) { const string typedBaseItemsQuery = """ @@ -105,7 +108,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine Audio, ExternalServiceId, IsInMixedFolder, DateLastSaved, LockedFields, Studios, Tags, TrailerTypes, OriginalTitle, PrimaryVersionId, DateLastMediaAdded, Album, LUFS, NormalizationGain, CriticRating, IsVirtualItem, SeriesName, UserDataKey, SeasonName, SeasonId, SeriesId, PresentationUniqueKey, InheritedParentalRatingValue, ExternalSeriesId, Tagline, ProviderIds, Images, ProductionLocations, ExtraIds, TotalBitrate, - ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName, UnratedType FROM TypedBaseItems + ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName, UnratedType, IsFolder FROM TypedBaseItems """; using (new TrackedMigrationStep("Loading TypedBaseItems", _logger)) { @@ -121,13 +124,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine } } - using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entries", _logger)) + using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entries", _logger)) { operation.JellyfinDbContext.SaveChanges(); } } - using (var operation = GetPreparedDbContext("moving ItemValues")) + using (var operation = GetPreparedDbContext("Moving ItemValues")) { // do not migrate inherited types as they are now properly mapped in search and lookup. const string itemValueQuery = @@ -138,7 +141,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine // EFCores local lookup sucks. We cannot use context.ItemValues.Local here because its just super slow. var localItems = new Dictionary<(int Type, string Value), (Database.Implementations.Entities.ItemValue ItemValue, List<Guid> ItemIds)>(); - using (new TrackedMigrationStep("loading ItemValues", _logger)) + using (new TrackedMigrationStep("Loading ItemValues", _logger)) { foreach (SqliteDataReader dto in connection.Query(itemValueQuery)) { @@ -166,13 +169,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine } } - using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues entries", _logger)) + using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues entries", _logger)) { operation.JellyfinDbContext.SaveChanges(); } } - using (var operation = GetPreparedDbContext("moving UserData")) + using (var operation = GetPreparedDbContext("Moving UserData")) { var queryResult = connection.Query( """ @@ -181,14 +184,14 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key) """); - using (new TrackedMigrationStep("loading UserData", _logger)) + using (new TrackedMigrationStep("Loading UserData", _logger)) { - var users = operation.JellyfinDbContext.Users.AsNoTracking().ToImmutableArray(); + var users = operation.JellyfinDbContext.Users.AsNoTracking().ToArray(); var userIdBlacklist = new HashSet<int>(); foreach (var entity in queryResult) { - var userData = GetUserData(users, entity, userIdBlacklist); + var userData = GetUserData(users, entity, userIdBlacklist, _logger); if (userData is null) { var userDataId = entity.GetString(0); @@ -212,19 +215,17 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine userData.ItemId = refItem.Id; operation.JellyfinDbContext.UserData.Add(userData); } - - users.Clear(); } legacyBaseItemWithUserKeys.Clear(); - using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries", _logger)) + using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries", _logger)) { operation.JellyfinDbContext.SaveChanges(); } } - using (var operation = GetPreparedDbContext("moving MediaStreamInfos")) + using (var operation = GetPreparedDbContext("Moving MediaStreamInfos")) { const string mediaStreamQuery = """ @@ -237,7 +238,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = MediaStreams.ItemId) """; - using (new TrackedMigrationStep("loading MediaStreamInfos", _logger)) + using (new TrackedMigrationStep("Loading MediaStreamInfos", _logger)) { foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery)) { @@ -245,13 +246,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine } } - using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStreamInfos entries", _logger)) + using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStreamInfos entries", _logger)) { operation.JellyfinDbContext.SaveChanges(); } } - using (var operation = GetPreparedDbContext("moving AttachmentStreamInfos")) + using (var operation = GetPreparedDbContext("Moving AttachmentStreamInfos")) { const string mediaAttachmentQuery = """ @@ -260,7 +261,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = mediaattachments.ItemId) """; - using (new TrackedMigrationStep("loading AttachmentStreamInfos", _logger)) + using (new TrackedMigrationStep("Loading AttachmentStreamInfos", _logger)) { foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery)) { @@ -268,13 +269,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine } } - using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger)) + using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger)) { operation.JellyfinDbContext.SaveChanges(); } } - using (var operation = GetPreparedDbContext("moving People")) + using (var operation = GetPreparedDbContext("Moving People")) { const string personsQuery = """ @@ -284,14 +285,14 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine var peopleCache = new Dictionary<string, (People Person, List<PeopleBaseItemMap> Items)>(); - using (new TrackedMigrationStep("loading People", _logger)) + using (new TrackedMigrationStep("Loading People", _logger)) { foreach (SqliteDataReader reader in connection.Query(personsQuery)) { var itemId = reader.GetGuid(0); if (!baseItemIds.Contains(itemId)) { - _logger.LogError("Dont save person {0} because its not in use by any BaseItem", reader.GetString(1)); + _logger.LogError("Not saving person {0} because it's not in use by any BaseItem", reader.GetString(1)); continue; } @@ -330,13 +331,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine peopleCache.Clear(); } - using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries and {operation.JellyfinDbContext.PeopleBaseItemMap.Local.Count} maps", _logger)) + using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries and {operation.JellyfinDbContext.PeopleBaseItemMap.Local.Count} maps", _logger)) { operation.JellyfinDbContext.SaveChanges(); } } - using (var operation = GetPreparedDbContext("moving Chapters")) + using (var operation = GetPreparedDbContext("Moving Chapters")) { const string chapterQuery = """ @@ -344,7 +345,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = Chapters2.ItemId) """; - using (new TrackedMigrationStep("loading Chapters", _logger)) + using (new TrackedMigrationStep("Loading Chapters", _logger)) { foreach (SqliteDataReader dto in connection.Query(chapterQuery)) { @@ -353,13 +354,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine } } - using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries", _logger)) + using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries", _logger)) { operation.JellyfinDbContext.SaveChanges(); } } - using (var operation = GetPreparedDbContext("moving AncestorIds")) + using (var operation = GetPreparedDbContext("Moving AncestorIds")) { const string ancestorIdsQuery = """ @@ -370,7 +371,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.AncestorId) """; - using (new TrackedMigrationStep("loading AncestorIds", _logger)) + using (new TrackedMigrationStep("Loading AncestorIds", _logger)) { foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery)) { @@ -379,7 +380,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine } } - using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId entries", _logger)) + using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId entries", _logger)) { operation.JellyfinDbContext.SaveChanges(); } @@ -404,19 +405,20 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine return new DatabaseMigrationStep(dbContext, operationName, _logger); } - private UserData? GetUserData(ImmutableArray<User> users, SqliteDataReader dto, HashSet<int> userIdBlacklist) + internal static UserData? GetUserData(User[] users, SqliteDataReader dto, HashSet<int> userIdBlacklist, ILogger logger) { var internalUserId = dto.GetInt32(1); - var user = users.FirstOrDefault(e => e.InternalId == internalUserId); + if (userIdBlacklist.Contains(internalUserId)) + { + return null; + } + var user = users.FirstOrDefault(e => e.InternalId == internalUserId); if (user is null) { - if (userIdBlacklist.Contains(internalUserId)) - { - return null; - } + userIdBlacklist.Add(internalUserId); - _logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length); + logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length); return null; } @@ -1168,7 +1170,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine entity.UnratedType = unratedType; } - var baseItem = BaseItemRepository.DeserialiseBaseItem(entity, _logger, null, false); + if (reader.TryGetBoolean(index++, out var isFolder)) + { + entity.IsFolder = isFolder; + } + + var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false); var dataKeys = baseItem.GetUserDataKeys(); userDataKeys.AddRange(dataKeys); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs index 2d5fc2a0d..d4cc9bbee 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs @@ -26,7 +26,7 @@ public class MigrateLibraryDbCompatibilityCheck : IAsyncMigrationRoutine /// </summary> /// <param name="startupLogger">The startup logger.</param> /// <param name="paths">The Path service.</param> - public MigrateLibraryDbCompatibilityCheck(IStartupLogger startupLogger, IServerApplicationPaths paths) + public MigrateLibraryDbCompatibilityCheck(IStartupLogger<MigrateLibraryDbCompatibilityCheck> startupLogger, IServerApplicationPaths paths) { _logger = startupLogger; _paths = paths; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs new file mode 100644 index 000000000..8a0a1741f --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs @@ -0,0 +1,123 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +[JellyfinMigration("2025-06-18T01:00:00", nameof(MigrateLibraryUserData))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class MigrateLibraryUserData : IAsyncMigrationRoutine +{ + private const string DbFilename = "library.db.old"; + + private readonly IStartupLogger _logger; + private readonly IServerApplicationPaths _paths; + private readonly IDbContextFactory<JellyfinDbContext> _provider; + + public MigrateLibraryUserData( + IStartupLogger<MigrateLibraryDb> startupLogger, + IDbContextFactory<JellyfinDbContext> provider, + IServerApplicationPaths paths) + { + _logger = startupLogger; + _provider = provider; + _paths = paths; + } + + public async Task PerformAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Migrating the userdata from library.db.old may take a while, do not stop Jellyfin."); + + var dataPath = _paths.DataPath; + var libraryDbPath = Path.Combine(dataPath, DbFilename); + if (!File.Exists(libraryDbPath)) + { + _logger.LogError("Cannot migrate userdata from {LibraryDb} as it does not exist. This migration expects the MigrateLibraryDb to run first.", libraryDbPath); + return; + } + + var dbContext = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + if (!await dbContext.BaseItems.AnyAsync(e => e.Id == BaseItemRepository.PlaceholderId, cancellationToken).ConfigureAwait(false)) + { + // the placeholder baseitem has been deleted by the librarydb migration so we need to readd it. + await dbContext.BaseItems.AddAsync( + new Database.Implementations.Entities.BaseItemEntity() + { + Id = BaseItemRepository.PlaceholderId, + Type = "PLACEHOLDER", + Name = "This is a placeholder item for UserData that has been detacted from its original item" + }, + cancellationToken) + .ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + var users = dbContext.Users.AsNoTracking().ToArray(); + var userIdBlacklist = new HashSet<int>(); + using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly"); + var retentionDate = DateTime.UtcNow; + + var queryResult = connection.Query( +""" + SELECT key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex FROM UserDatas + + WHERE NOT EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key) +"""); + + var importedUserData = new Dictionary<Guid, List<UserData>>(); + foreach (var entity in queryResult) + { + var userData = MigrateLibraryDb.GetUserData(users, entity, userIdBlacklist, _logger); + if (userData is null) + { + var userDataId = entity.GetString(0); + var internalUserId = entity.GetInt32(1); + + if (!userIdBlacklist.Contains(internalUserId)) + { + _logger.LogError("Was not able to migrate user data with key {0} because its id {InternalId} does not match any existing user.", userDataId, internalUserId); + userIdBlacklist.Add(internalUserId); + } + + continue; + } + + var ogId = userData.ItemId; + userData.ItemId = BaseItemRepository.PlaceholderId; + userData.RetentionDate = retentionDate; + if (!importedUserData.TryGetValue(ogId, out var importUserData)) + { + importUserData = []; + importedUserData[ogId] = importUserData; + } + + importUserData.Add(userData); + } + + foreach (var item in importedUserData) + { + await dbContext.UserData.Where(e => e.ItemId == item.Key).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + dbContext.UserData.AddRange(item.Value.DistinctBy(e => e.CustomDataKey)); // old userdata can have fucked up duplicates + } + + _logger.LogInformation("Try saving {NewSaved} UserData entries.", dbContext.UserData.Local.Count); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs index ae93557de..2a6db01cf 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs @@ -23,7 +23,7 @@ internal class MigrateRatingLevels : IDatabaseMigrationRoutine public MigrateRatingLevels( IDbContextFactory<JellyfinDbContext> provider, - IStartupLogger logger, + IStartupLogger<MigrateRatingLevels> logger, ILocalizationManager localizationManager) { _provider = provider; diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs index 6f650f731..8b394dd7a 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs @@ -47,7 +47,7 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine public MoveExtractedFiles( IApplicationPaths appPaths, ILogger<MoveExtractedFiles> logger, - IStartupLogger startupLogger, + IStartupLogger<MoveExtractedFiles> startupLogger, IPathManager pathManager, IFileSystem fileSystem, IDbContextFactory<JellyfinDbContext> dbProvider) diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs index a674aa928..0f55465e8 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs @@ -37,7 +37,7 @@ public class MoveTrickplayFiles : IMigrationRoutine ITrickplayManager trickplayManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IStartupLogger logger) + IStartupLogger<MoveTrickplayFiles> logger) { _trickplayManager = trickplayManager; _fileSystem = fileSystem; diff --git a/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs b/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs new file mode 100644 index 000000000..502763ac0 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs @@ -0,0 +1,74 @@ +#pragma warning disable RS0030 // Do not use banned APIs +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +[JellyfinMigration("2025-07-30T21:50:00", nameof(ReseedFolderFlag))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class ReseedFolderFlag : IAsyncMigrationRoutine +{ + private const string DbFilename = "library.db.old"; + + private readonly IStartupLogger _logger; + private readonly IServerApplicationPaths _paths; + private readonly IDbContextFactory<JellyfinDbContext> _provider; + + public ReseedFolderFlag( + IStartupLogger<MigrateLibraryDb> startupLogger, + IDbContextFactory<JellyfinDbContext> provider, + IServerApplicationPaths paths) + { + _logger = startupLogger; + _provider = provider; + _paths = paths; + } + + internal static bool RerunGuardFlag { get; set; } = false; + + public async Task PerformAsync(CancellationToken cancellationToken) + { + if (RerunGuardFlag) + { + _logger.LogInformation("Migration is skipped because it does not apply."); + return; + } + + _logger.LogInformation("Migrating the IsFolder flag from library.db.old may take a while, do not stop Jellyfin."); + + var dataPath = _paths.DataPath; + var libraryDbPath = Path.Combine(dataPath, DbFilename); + if (!File.Exists(libraryDbPath)) + { + _logger.LogError("Cannot migrate IsFolder flag from {LibraryDb} as it does not exist. This migration expects the MigrateLibraryDb to run first.", libraryDbPath); + return; + } + + var dbContext = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly"); + var queryResult = connection.Query( + """ + SELECT guid FROM TypedBaseItems + WHERE IsFolder = true + """) + .Select(entity => entity.GetGuid(0)) + .ToList(); + _logger.LogInformation("Migrating the IsFolder flag for {Count} items.", queryResult.Count); + foreach (var id in queryResult) + { + await dbContext.BaseItems.Where(e => e.Id == id).ExecuteUpdateAsync(e => e.SetProperty(f => f.IsFolder, true), cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs index 47ed26965..264710bce 100644 --- a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs +++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Jellyfin.Server.ServerSetupApp; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Stages; @@ -21,11 +22,13 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!; } - private ServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger) + private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger) { - var childServiceCollection = new ServiceCollection(); - childServiceCollection.AddSingleton(serviceProvider); - childServiceCollection.AddSingleton(logger); + var childServiceCollection = new ServiceCollection() + .AddSingleton(serviceProvider) + .AddSingleton(logger) + .AddSingleton(typeof(IStartupLogger<>), typeof(NestedStartupLogger<>)) + .AddSingleton<StartupLogTopic>(logger.Topic!); foreach (ServiceDescriptor service in serviceProvider.GetRequiredService<IServiceCollection>()) { @@ -78,4 +81,11 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type"); } } + + private class NestedStartupLogger<TCategory> : StartupLogger<TCategory> + { + public NestedStartupLogger(ILogger logger, StartupLogTopic topic) : base(logger, topic) + { + } + } } diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 0b77d63ac..dc7fa5eb3 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -60,7 +60,7 @@ namespace Jellyfin.Server private static long _startTimestamp; private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; - private static IStartupLogger? _migrationLogger; + private static IStartupLogger<JellyfinMigrationService>? _migrationLogger; private static string? _restoreFromBackup; /// <summary> @@ -103,6 +103,7 @@ namespace Jellyfin.Server _setupServer = new SetupServer(static () => _jellyfinHost?.Services?.GetService<INetworkManager>(), appPaths, static () => _appHost, _loggerFactory, startupConfig); await _setupServer.RunAsync().ConfigureAwait(false); _logger = _loggerFactory.CreateLogger("Main"); + StartupLogger.Logger = new StartupLogger(_logger); // Use the logging framework for uncaught exceptions instead of std error AppDomain.CurrentDomain.UnhandledException += (_, e) @@ -178,7 +179,9 @@ namespace Jellyfin.Server }) .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig)) .UseSerilog() - .ConfigureServices(e => e.AddTransient<IStartupLogger, StartupLogger>().AddSingleton<IServiceCollection>(e)) + .ConfigureServices(e => e + .RegisterStartupLogger() + .AddSingleton<IServiceCollection>(e)) .Build(); // Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection. @@ -268,7 +271,7 @@ namespace Jellyfin.Server /// <returns>A task.</returns> public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig) { - _migrationLogger = StartupLogger.Logger.BeginGroup($"Migration Service"); + _migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service"); var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer()); startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]); var migrationStartupServiceProvider = new ServiceCollection() @@ -276,7 +279,7 @@ namespace Jellyfin.Server .AddJellyfinDbContext(startupConfigurationManager, startupConfig) .AddSingleton<IApplicationPaths>(appPaths) .AddSingleton<ServerApplicationPaths>(appPaths) - .AddSingleton<IStartupLogger>(_migrationLogger); + .RegisterStartupLogger(); migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider); var startupService = migrationStartupServiceProvider.BuildServiceProvider(); diff --git a/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs index 2c2ef05f8..e7c193936 100644 --- a/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs +++ b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs @@ -1,5 +1,4 @@ using System; -using Morestachio.Helper.Logging; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Jellyfin.Server.ServerSetupApp; @@ -10,6 +9,11 @@ namespace Jellyfin.Server.ServerSetupApp; public interface IStartupLogger : ILogger { /// <summary> + /// Gets the topic this logger is assigned to. + /// </summary> + StartupLogTopic? Topic { get; } + + /// <summary> /// Adds another logger instance to this logger for combined logging. /// </summary> /// <param name="logger">Other logger to rely messages to.</param> @@ -22,4 +26,41 @@ public interface IStartupLogger : ILogger /// <param name="logEntry">Defines the log message that introduces the new group.</param> /// <returns>A new logger that can write to the group.</returns> IStartupLogger BeginGroup(FormattableString logEntry); + + /// <summary> + /// Adds another logger instance to this logger for combined logging. + /// </summary> + /// <param name="logger">Other logger to rely messages to.</param> + /// <returns>A combined logger.</returns> + /// <typeparam name="TCategory">The logger cateogry.</typeparam> + IStartupLogger<TCategory> With<TCategory>(ILogger logger); + + /// <summary> + /// Opens a new Group logger within the parent logger. + /// </summary> + /// <param name="logEntry">Defines the log message that introduces the new group.</param> + /// <returns>A new logger that can write to the group.</returns> + /// <typeparam name="TCategory">The logger cateogry.</typeparam> + IStartupLogger<TCategory> BeginGroup<TCategory>(FormattableString logEntry); +} + +/// <summary> +/// Defines a logger that can be injected via DI to get a startup logger initialised with an logger framework connected <see cref="ILogger"/>. +/// </summary> +/// <typeparam name="TCategory">The logger cateogry.</typeparam> +public interface IStartupLogger<TCategory> : IStartupLogger +{ + /// <summary> + /// Adds another logger instance to this logger for combined logging. + /// </summary> + /// <param name="logger">Other logger to rely messages to.</param> + /// <returns>A combined logger.</returns> + new IStartupLogger<TCategory> With(ILogger logger); + + /// <summary> + /// Opens a new Group logger within the parent logger. + /// </summary> + /// <param name="logEntry">Defines the log message that introduces the new group.</param> + /// <returns>A new logger that can write to the group.</returns> + new IStartupLogger<TCategory> BeginGroup(FormattableString logEntry); } diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index d88dbee57..92e012940 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Emby.Server.Implementations.Configuration; using Emby.Server.Implementations.Serialization; using Jellyfin.Networking.Manager; +using Jellyfin.Server.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller; @@ -27,6 +28,8 @@ using Microsoft.Extensions.Primitives; using Morestachio; using Morestachio.Framework.IO.SingleStream; using Morestachio.Rendering; +using Serilog; +using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Jellyfin.Server.ServerSetupApp; @@ -71,7 +74,7 @@ public sealed class SetupServer : IDisposable _configurationManager.RegisterConfiguration<NetworkConfigurationFactory>(); } - internal static ConcurrentQueue<StartupLogEntry>? LogQueue { get; set; } = new(); + internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new(); /// <summary> /// Gets a value indicating whether Startup server is currently running. @@ -88,12 +91,12 @@ public sealed class SetupServer : IDisposable _startupUiRenderer = (await ParserOptionsBuilder.New() .WithTemplate(fileTemplate) .WithFormatter( - (StartupLogEntry logEntry, IEnumerable<StartupLogEntry> children) => + (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) => { if (children.Any()) { var maxLevel = logEntry.LogLevel; - var stack = new Stack<StartupLogEntry>(children); + var stack = new Stack<StartupLogTopic>(children); while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) != null) // error is the highest inherted error level. { @@ -138,19 +141,25 @@ public sealed class SetupServer : IDisposable ThrowIfDisposed(); var retryAfterValue = TimeSpan.FromSeconds(5); - _startupServer = Host.CreateDefaultBuilder() + var config = _configurationManager.GetNetworkConfiguration()!; + _startupServer = Host.CreateDefaultBuilder(["hostBuilder:reloadConfigOnChange=false"]) .UseConsoleLifetime() + .UseSerilog() .ConfigureServices(serv => { + serv.AddSingleton(this); serv.AddHealthChecks() .AddCheck<SetupHealthcheck>("StartupCheck"); + serv.Configure<ForwardedHeadersOptions>(options => + { + ApiServiceCollectionExtensions.ConfigureForwardHeaders(config, options); + }); }) .ConfigureWebHostDefaults(webHostBuilder => { webHostBuilder .UseKestrel((builderContext, options) => { - var config = _configurationManager.GetNetworkConfiguration()!; var knownBindInterfaces = NetworkManager.GetInterfacesCore(_loggerFactory.CreateLogger<SetupServer>(), config.EnableIPv4, config.EnableIPv6); knownBindInterfaces = NetworkManager.FilterBindSettings(config, knownBindInterfaces.ToList(), config.EnableIPv4, config.EnableIPv6); var bindInterfaces = NetworkManager.GetAllBindInterfaces(false, _configurationManager, knownBindInterfaces, config.EnableIPv4, config.EnableIPv6); @@ -168,7 +177,7 @@ public sealed class SetupServer : IDisposable .Configure(app => { app.UseHealthChecks("/health"); - + app.UseForwardedHeaders(); app.Map("/startup/logger", loggerRoute => { loggerRoute.Run(async context => @@ -362,15 +371,4 @@ public sealed class SetupServer : IDisposable }); } } - - internal class StartupLogEntry - { - public LogLevel LogLevel { get; set; } - - public string? Content { get; set; } - - public DateTimeOffset DateOfCreation { get; set; } - - public List<StartupLogEntry> Children { get; set; } = []; - } } diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs b/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs new file mode 100644 index 000000000..cd440a9b5 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// Defines a topic for the Startup UI. +/// </summary> +public class StartupLogTopic +{ + /// <summary> + /// Gets or Sets the LogLevel. + /// </summary> + public LogLevel LogLevel { get; set; } + + /// <summary> + /// Gets or Sets the descriptor for the topic. + /// </summary> + public string? Content { get; set; } + + /// <summary> + /// Gets or sets the time the topic was created. + /// </summary> + public DateTimeOffset DateOfCreation { get; set; } + + /// <summary> + /// Gets the child items of this topic. + /// </summary> + public Collection<StartupLogTopic> Children { get; } = []; +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs index 2b86dc0c1..0121854ce 100644 --- a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs +++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs @@ -1,56 +1,86 @@ using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using Jellyfin.Server.Migrations.Routines; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Jellyfin.Server.ServerSetupApp; /// <inheritdoc/> public class StartupLogger : IStartupLogger { - private readonly SetupServer.StartupLogEntry? _groupEntry; + private readonly StartupLogTopic? _topic; /// <summary> /// Initializes a new instance of the <see cref="StartupLogger"/> class. /// </summary> - public StartupLogger() + /// <param name="logger">The underlying base logger.</param> + public StartupLogger(ILogger logger) { - Loggers = []; + BaseLogger = logger; } /// <summary> /// Initializes a new instance of the <see cref="StartupLogger"/> class. /// </summary> - private StartupLogger(SetupServer.StartupLogEntry? groupEntry) : this() + /// <param name="logger">The underlying base logger.</param> + /// <param name="topic">The group for this logger.</param> + internal StartupLogger(ILogger logger, StartupLogTopic? topic) : this(logger) { - _groupEntry = groupEntry; + _topic = topic; } - internal static IStartupLogger Logger { get; } = new StartupLogger(); + internal static IStartupLogger Logger { get; set; } = new StartupLogger(NullLogger.Instance); - private List<ILogger> Loggers { get; set; } + /// <inheritdoc/> + public StartupLogTopic? Topic => _topic; + + /// <summary> + /// Gets or Sets the underlying base logger. + /// </summary> + protected ILogger BaseLogger { get; set; } /// <inheritdoc/> public IStartupLogger BeginGroup(FormattableString logEntry) { - var startupEntry = new SetupServer.StartupLogEntry() + return new StartupLogger(BaseLogger, AddToTopic(logEntry)); + } + + /// <inheritdoc/> + public IStartupLogger With(ILogger logger) + { + return new StartupLogger(logger, Topic); + } + + /// <inheritdoc/> + public IStartupLogger<TCategory> With<TCategory>(ILogger logger) + { + return new StartupLogger<TCategory>(logger, Topic); + } + + /// <inheritdoc/> + public IStartupLogger<TCategory> BeginGroup<TCategory>(FormattableString logEntry) + { + return new StartupLogger<TCategory>(BaseLogger, AddToTopic(logEntry)); + } + + private StartupLogTopic AddToTopic(FormattableString logEntry) + { + var startupEntry = new StartupLogTopic() { Content = logEntry.ToString(CultureInfo.InvariantCulture), DateOfCreation = DateTimeOffset.Now }; - if (_groupEntry is null) + if (Topic is null) { SetupServer.LogQueue?.Enqueue(startupEntry); } else { - _groupEntry.Children.Add(startupEntry); + Topic.Children.Add(startupEntry); } - return new StartupLogger(startupEntry); + return startupEntry; } /// <inheritdoc/> @@ -69,34 +99,26 @@ public class StartupLogger : IStartupLogger /// <inheritdoc/> public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { - foreach (var item in Loggers.Where(e => e.IsEnabled(logLevel))) + if (BaseLogger.IsEnabled(logLevel)) { - item.Log(logLevel, eventId, state, exception, formatter); + // if enabled allow the base logger also to receive the message + BaseLogger.Log(logLevel, eventId, state, exception, formatter); } - var startupEntry = new SetupServer.StartupLogEntry() + var startupEntry = new StartupLogTopic() { LogLevel = logLevel, Content = formatter(state, exception), DateOfCreation = DateTimeOffset.Now }; - if (_groupEntry is null) + if (Topic is null) { SetupServer.LogQueue?.Enqueue(startupEntry); } else { - _groupEntry.Children.Add(startupEntry); + Topic.Children.Add(startupEntry); } } - - /// <inheritdoc/> - public IStartupLogger With(ILogger logger) - { - return new StartupLogger(_groupEntry) - { - Loggers = [.. Loggers, logger] - }; - } } diff --git a/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs b/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs new file mode 100644 index 000000000..ada4b56a7 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs @@ -0,0 +1,18 @@ +using System; +using System.Globalization; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Jellyfin.Server.ServerSetupApp; + +internal static class StartupLoggerExtensions +{ + public static IServiceCollection RegisterStartupLogger(this IServiceCollection services) + { + return services + .AddTransient<IStartupLogger, StartupLogger<Startup>>() + .AddTransient(typeof(IStartupLogger<>), typeof(StartupLogger<>)); + } +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs b/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs new file mode 100644 index 000000000..64da0ce88 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs @@ -0,0 +1,56 @@ +using System; +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// Startup logger for usage with DI that utilises an underlying logger from the DI. +/// </summary> +/// <typeparam name="TCategory">The category of the underlying logger.</typeparam> +#pragma warning disable SA1649 // File name should match first type name +public class StartupLogger<TCategory> : StartupLogger, IStartupLogger<TCategory> +#pragma warning restore SA1649 // File name should match first type name +{ + /// <summary> + /// Initializes a new instance of the <see cref="StartupLogger{TCategory}"/> class. + /// </summary> + /// <param name="logger">The injected base logger.</param> + public StartupLogger(ILogger<TCategory> logger) : base(logger) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="StartupLogger{TCategory}"/> class. + /// </summary> + /// <param name="logger">The underlying base logger.</param> + /// <param name="groupEntry">The group for this logger.</param> + internal StartupLogger(ILogger logger, StartupLogTopic? groupEntry) : base(logger, groupEntry) + { + } + + IStartupLogger<TCategory> IStartupLogger<TCategory>.BeginGroup(FormattableString logEntry) + { + var startupEntry = new StartupLogTopic() + { + Content = logEntry.ToString(CultureInfo.InvariantCulture), + DateOfCreation = DateTimeOffset.Now + }; + + if (Topic is null) + { + SetupServer.LogQueue?.Enqueue(startupEntry); + } + else + { + Topic.Children.Add(startupEntry); + } + + return new StartupLogger<TCategory>(BaseLogger, startupEntry); + } + + IStartupLogger<TCategory> IStartupLogger<TCategory>.With(ILogger logger) + { + return new StartupLogger<TCategory>(logger, Topic); + } +} diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html index 747835b2a..523f38d74 100644 --- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -204,6 +204,7 @@ </li> {{--| /DECLARE}} + {{#IF localNetworkRequest}} <div class="flex-col"> <ol class="action-list"> {{#FOREACH log IN logs.Reverse()}} @@ -211,6 +212,10 @@ {{/FOREACH}} </ol> </div> + {{#ELSE}} + <p>Please visit this page from your local network to view detailed startup logs.</p> + {{/ELSE}} + {{/IF}} </div> </body> diff --git a/Jellyfin.sln b/Jellyfin.sln index cdc8c8f65..21ef13e72 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -88,6 +88,9 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv", "src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj", "{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyfin.Database", "{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}" + ProjectSection(SolutionItems) = preProject + src\Jellyfin.Database\readme.md = src\Jellyfin.Database\readme.md + EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}" EndProject diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index c2efa4ad3..bb0b26b8e 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1423,23 +1423,14 @@ namespace MediaBrowser.Controller.Entities public virtual bool RequiresRefresh() { - if (string.IsNullOrEmpty(Path) || DateModified == default) + if (string.IsNullOrEmpty(Path) || DateModified == DateTime.MinValue) { return false; } var info = FileSystem.GetFileSystemInfo(Path); - if (info.Exists) - { - if (info.IsDirectory) - { - return info.LastWriteTimeUtc != DateModified; - } - return info.LastWriteTimeUtc != DateModified; - } - - return false; + return info.Exists && this.HasChanged(info.LastWriteTimeUtc); } public virtual List<string> GetUserDataKeys() @@ -2002,9 +1993,10 @@ namespace MediaBrowser.Controller.Entities } // Remove from file system - if (info.IsLocalFile) + var path = info.Path; + if (info.IsLocalFile && !string.IsNullOrWhiteSpace(path)) { - FileSystem.DeleteFile(info.Path); + FileSystem.DeleteFile(path); } // Remove from item diff --git a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs index dcd22a3b4..668e2c1e2 100644 --- a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs +++ b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs @@ -114,5 +114,19 @@ namespace MediaBrowser.Controller.Entities source.DeepCopy(dest); return dest; } + + /// <summary> + /// Determines if the item has changed. + /// </summary> + /// <param name="source">The source object.</param> + /// <param name="asOf">The timestamp to detect changes as of.</param> + /// <typeparam name="T">Source type.</typeparam> + /// <returns>Whether the item has changed.</returns> + public static bool HasChanged<T>(this T source, DateTime asOf) + where T : BaseItem + { + ArgumentNullException.ThrowIfNull(source); + return source.DateModified.Subtract(asOf).Duration().TotalSeconds > 1; + } } } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index e0c3b0a93..06cbcc2e1 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -11,7 +11,6 @@ using System.Security; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using System.Threading.Tasks.Dataflow; using J2N.Collections.Generic.Extensions; using Jellyfin.Data; using Jellyfin.Data.Enums; @@ -25,6 +24,7 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LibraryTaskScheduler; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; @@ -49,6 +49,8 @@ namespace MediaBrowser.Controller.Entities public static IUserViewManager UserViewManager { get; set; } + public static ILimitedConcurrencyLibraryScheduler LimitedConcurrencyLibraryScheduler { get; set; } + /// <summary> /// Gets or sets a value indicating whether this instance is root. /// </summary> @@ -598,51 +600,13 @@ namespace MediaBrowser.Controller.Entities /// <returns>Task.</returns> private async Task RunTasks<T>(Func<T, IProgress<double>, Task> task, IList<T> children, IProgress<double> progress, CancellationToken cancellationToken) { - var childrenCount = children.Count; - var childrenProgress = new double[childrenCount]; - - void UpdateProgress() - { - progress.Report(childrenProgress.Average()); - } - - var fanoutConcurrency = ConfigurationManager.Configuration.LibraryScanFanoutConcurrency; - var parallelism = fanoutConcurrency > 0 ? fanoutConcurrency : Environment.ProcessorCount; - - var actionBlock = new ActionBlock<int>( - async i => - { - var innerProgress = new Progress<double>(innerPercent => - { - // round the percent and only update progress if it changed to prevent excessive UpdateProgress calls - var innerPercentRounded = Math.Round(innerPercent); - if (childrenProgress[i] != innerPercentRounded) - { - childrenProgress[i] = innerPercentRounded; - UpdateProgress(); - } - }); - - await task(children[i], innerProgress).ConfigureAwait(false); - - childrenProgress[i] = 100; - - UpdateProgress(); - }, - new ExecutionDataflowBlockOptions - { - MaxDegreeOfParallelism = parallelism, - CancellationToken = cancellationToken, - }); - - for (var i = 0; i < childrenCount; i++) - { - await actionBlock.SendAsync(i, cancellationToken).ConfigureAwait(false); - } - - actionBlock.Complete(); - - await actionBlock.Completion.ConfigureAwait(false); + await LimitedConcurrencyLibraryScheduler + .Enqueue( + children.ToArray(), + task, + progress, + cancellationToken) + .ConfigureAwait(false); } /// <summary> @@ -731,7 +695,7 @@ namespace MediaBrowser.Controller.Entities items = GetRecursiveChildren(user, query); } - return PostFilterAndSort(items, query, true); + return PostFilterAndSort(items, query); } if (this is not UserRootFolder @@ -995,10 +959,10 @@ namespace MediaBrowser.Controller.Entities items = GetChildren(user, true, childQuery).Where(filter); } - return PostFilterAndSort(items, query, true); + return PostFilterAndSort(items, query); } - protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query, bool enableSorting) + protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query) { var user = query.User; @@ -1008,7 +972,7 @@ namespace MediaBrowser.Controller.Entities items = CollapseBoxSetItemsIfNeeded(items, query, this, user, ConfigurationManager, CollectionManager); } - #pragma warning disable CA1309 +#pragma warning disable CA1309 if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater)) { items = items.Where(i => string.Compare(query.NameStartsWithOrGreater, i.SortName, StringComparison.InvariantCultureIgnoreCase) < 1); @@ -1023,7 +987,7 @@ namespace MediaBrowser.Controller.Entities { items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1); } - #pragma warning restore CA1309 +#pragma warning restore CA1309 // This must be the last filter if (!query.AdjacentTo.IsNullOrEmpty()) @@ -1031,7 +995,7 @@ namespace MediaBrowser.Controller.Entities items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); } - return UserViewBuilder.SortAndPage(items, null, query, LibraryManager, enableSorting); + return UserViewBuilder.SortAndPage(items, null, query, LibraryManager); } private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded( diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index d50f3d075..b32b64f5d 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Diacritics.Extensions; using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; @@ -373,8 +374,15 @@ namespace MediaBrowser.Controller.Entities .Where(i => i != other) .Select(e => Enum.Parse<UnratedItem>(e, true)).ToArray(); - ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags); - IncludeInheritedTags = user.GetPreference(PreferenceKind.AllowedTags); + ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags) + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Select(tag => tag.RemoveDiacritics().ToLowerInvariant()) + .ToArray(); + + IncludeInheritedTags = user.GetPreference(PreferenceKind.AllowedTags) + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Select(tag => tag.RemoveDiacritics().ToLowerInvariant()) + .ToArray(); User = user; } diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index 408161b03..48211d99f 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -179,7 +179,7 @@ namespace MediaBrowser.Controller.Entities.TV var items = GetEpisodes(user, query.DtoOptions, true).Where(filter); - return PostFilterAndSort(items, query, false); + return PostFilterAndSort(items, query); } /// <summary> diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index b4ad05921..62c73d56f 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV query.AncestorWithPresentationUniqueKey = null; query.SeriesPresentationUniqueKey = seriesKey; query.IncludeItemTypes = new[] { BaseItemKind.Season }; - query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }; + query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) }; if (user is not null && !user.DisplayMissingEpisodes) { @@ -247,10 +247,6 @@ namespace MediaBrowser.Controller.Entities.TV query.AncestorWithPresentationUniqueKey = null; query.SeriesPresentationUniqueKey = seriesKey; - if (query.OrderBy.Count == 0) - { - query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }; - } if (query.IncludeItemTypes.Length == 0) { diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs index bc7e22d9a..deed3631b 100644 --- a/MediaBrowser.Controller/Entities/UserRootFolder.cs +++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs @@ -80,7 +80,7 @@ namespace MediaBrowser.Controller.Entities PresetViews = query.PresetViews }); - return UserViewBuilder.SortAndPage(result, null, query, LibraryManager, true); + return UserViewBuilder.SortAndPage(result, null, query, LibraryManager); } public override int GetChildCount(User user) diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 1eb3c8f50..7679d383f 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -438,22 +438,18 @@ namespace MediaBrowser.Controller.Entities items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); } - return SortAndPage(items, totalRecordLimit, query, libraryManager, true); + return SortAndPage(items, totalRecordLimit, query, libraryManager); } public static QueryResult<BaseItem> SortAndPage( IEnumerable<BaseItem> items, int? totalRecordLimit, InternalItemsQuery query, - ILibraryManager libraryManager, - bool enableSorting) + ILibraryManager libraryManager) { - if (enableSorting) + if (query.OrderBy.Count > 0) { - if (query.OrderBy.Count > 0) - { - items = libraryManager.Sort(items, query.User, query.OrderBy); - } + items = libraryManager.Sort(items, query.User, query.OrderBy); } var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray(); diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs index 0109cf4b7..7f06a318a 100644 --- a/MediaBrowser.Controller/Library/IUserManager.cs +++ b/MediaBrowser.Controller/Library/IUserManager.cs @@ -34,6 +34,12 @@ namespace MediaBrowser.Controller.Library IEnumerable<Guid> UsersIds { get; } /// <summary> + /// Checks if the user's username is valid. + /// </summary> + /// <param name="name">The user's username.</param> + void ThrowIfInvalidUsername(string name); + + /// <summary> /// Initializes the user manager and ensures that a user exists. /// </summary> /// <returns>Awaitable task.</returns> diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/ILimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/ILimitedConcurrencyLibraryScheduler.cs new file mode 100644 index 000000000..e7460a2e6 --- /dev/null +++ b/MediaBrowser.Controller/LibraryTaskScheduler/ILimitedConcurrencyLibraryScheduler.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.LibraryTaskScheduler; + +/// <summary> +/// Provides a shared scheduler to run library related tasks based on the <see cref="ServerConfiguration.LibraryScanFanoutConcurrency"/>. +/// </summary> +public interface ILimitedConcurrencyLibraryScheduler +{ + /// <summary> + /// Enqueues an action that will be invoked with the set data. + /// </summary> + /// <typeparam name="T">The data Type.</typeparam> + /// <param name="data">The data.</param> + /// <param name="worker">The callback to process the data.</param> + /// <param name="progress">A progress reporter.</param> + /// <param name="cancellationToken">Stop token.</param> + /// <returns>A task that finishes when all data has been processed by the worker.</returns> + Task Enqueue<T>(T[] data, Func<T, IProgress<double>, Task> worker, IProgress<double> progress, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs new file mode 100644 index 000000000..0de5f198d --- /dev/null +++ b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.LibraryTaskScheduler; + +/// <summary> +/// Provides Parallel action interface to process tasks with a set concurrency level. +/// </summary> +public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibraryScheduler, IAsyncDisposable +{ + private const int CleanupGracePeriod = 60; + private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly ILogger<LimitedConcurrencyLibraryScheduler> _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly Dictionary<CancellationTokenSource, Task> _taskRunners = new(); + + private static readonly AsyncLocal<CancellationTokenSource> _deadlockDetector = new(); + + /// <summary> + /// Gets used to lock all operations on the Tasks queue and creating workers. + /// </summary> + private readonly Lock _taskLock = new(); + + private readonly BlockingCollection<TaskQueueItem> _tasks = new(); + + private volatile int _workCounter; + private Task? _cleanupTask; + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="LimitedConcurrencyLibraryScheduler"/> class. + /// </summary> + /// <param name="hostApplicationLifetime">The hosting lifetime.</param> + /// <param name="logger">The logger.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + public LimitedConcurrencyLibraryScheduler( + IHostApplicationLifetime hostApplicationLifetime, + ILogger<LimitedConcurrencyLibraryScheduler> logger, + IServerConfigurationManager serverConfigurationManager) + { + _hostApplicationLifetime = hostApplicationLifetime; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + } + + private void ScheduleTaskCleanup() + { + lock (_taskLock) + { + if (_cleanupTask is not null) + { + _logger.LogDebug("Cleanup task already scheduled."); + // cleanup task is already running. + return; + } + + _cleanupTask = RunCleanupTask(); + } + + async Task RunCleanupTask() + { + _logger.LogDebug("Schedule cleanup task in {CleanupGracePerioid} sec.", CleanupGracePeriod); + await Task.Delay(TimeSpan.FromSeconds(CleanupGracePeriod)).ConfigureAwait(false); + if (_disposed) + { + _logger.LogDebug("Abort cleaning up, already disposed."); + return; + } + + lock (_taskLock) + { + if (_tasks.Count > 0 || _workCounter > 0) + { + _logger.LogDebug("Delay cleanup task, operations still running."); + // tasks are still there so its still in use. Reschedule cleanup task. + // we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended. + _cleanupTask = RunCleanupTask(); + return; + } + } + + _logger.LogDebug("Cleanup runners."); + foreach (var item in _taskRunners.ToArray()) + { + await item.Key.CancelAsync().ConfigureAwait(false); + _taskRunners.Remove(item.Key); + } + } + } + + private bool ShouldForceSequentialOperation() + { + // if the user either set the setting to 1 or it's unset and we have fewer than 4 cores it's better to run sequentially. + var fanoutSetting = _serverConfigurationManager.Configuration.LibraryScanFanoutConcurrency; + return fanoutSetting == 1 || (fanoutSetting <= 0 && Environment.ProcessorCount <= 3); + } + + private int CalculateScanConcurrencyLimit() + { + // when this is invoked, we already checked ShouldForceSequentialOperation for the sequential check. + var fanoutConcurrency = _serverConfigurationManager.Configuration.LibraryScanFanoutConcurrency; + if (fanoutConcurrency <= 0) + { + // in case the user did not set a limit manually, we can assume he has 3 or more cores as already checked by ShouldForceSequentialOperation. + return Environment.ProcessorCount - 3; + } + + return fanoutConcurrency; + } + + private void Worker() + { + lock (_taskLock) + { + var operationFanout = Math.Max(0, CalculateScanConcurrencyLimit() - _taskRunners.Count); + _logger.LogDebug("Spawn {NumberRunners} new runners.", operationFanout); + for (int i = 0; i < operationFanout; i++) + { + var stopToken = new CancellationTokenSource(); + var combinedSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken.Token, _hostApplicationLifetime.ApplicationStopping); + _taskRunners.Add( + combinedSource, + Task.Factory.StartNew( + ItemWorker, + (combinedSource, stopToken), + combinedSource.Token, + TaskCreationOptions.PreferFairness, + TaskScheduler.Default)); + } + } + } + + private async Task ItemWorker(object? obj) + { + var stopToken = ((CancellationTokenSource TaskStop, CancellationTokenSource GlobalStop))obj!; + _deadlockDetector.Value = stopToken.TaskStop; + try + { + foreach (var item in _tasks.GetConsumingEnumerable(stopToken.GlobalStop.Token)) + { + stopToken.GlobalStop.Token.ThrowIfCancellationRequested(); + try + { + var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0; + Debug.Assert(newWorkerLimit, "_workCounter > 0"); + _logger.LogDebug("Process new item '{Data}'.", item.Data); + await ProcessItem(item).ConfigureAwait(false); + } + finally + { + var newWorkerLimit = Interlocked.Decrement(ref _workCounter) >= 0; + Debug.Assert(newWorkerLimit, "_workCounter > 0"); + } + } + } + catch (OperationCanceledException) when (stopToken.TaskStop.IsCancellationRequested) + { + // thats how you do it, interupt the waiter thread. There is nothing to do here when it was on purpose. + } + finally + { + _logger.LogDebug("Cleanup Runner'."); + _deadlockDetector.Value = default!; + _taskRunners.Remove(stopToken.TaskStop); + stopToken.GlobalStop.Dispose(); + stopToken.TaskStop.Dispose(); + } + } + + private async Task ProcessItem(TaskQueueItem item) + { + try + { + if (item.CancellationToken.IsCancellationRequested) + { + // if item is cancelled, just skip it + return; + } + + await item.Worker(item.Data).ConfigureAwait(true); + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Error while performing a library operation"); + } + finally + { + item.Progress.Report(100); + item.Done.SetResult(); + } + } + + /// <inheritdoc/> + public async Task Enqueue<T>(T[] data, Func<T, IProgress<double>, Task> worker, IProgress<double> progress, CancellationToken cancellationToken) + { + if (_disposed) + { + return; + } + + if (data.Length == 0 || cancellationToken.IsCancellationRequested) + { + progress.Report(100); + return; + } + + _logger.LogDebug("Enqueue new Workset of {NoItems} items.", data.Length); + + TaskQueueItem[] workItems = null!; + + void UpdateProgress() + { + progress.Report(workItems.Select(e => e.ProgressValue).Average()); + } + + workItems = data.Select(item => + { + TaskQueueItem queueItem = null!; + return queueItem = new TaskQueueItem() + { + Data = item!, + Progress = new Progress<double>(innerPercent => + { + // round the percent and only update progress if it changed to prevent excessive UpdateProgress calls + var innerPercentRounded = Math.Round(innerPercent); + if (queueItem.ProgressValue != innerPercentRounded) + { + queueItem.ProgressValue = innerPercentRounded; + UpdateProgress(); + } + }), + Worker = (val) => worker((T)val, queueItem.Progress), + CancellationToken = cancellationToken + }; + }).ToArray(); + + if (ShouldForceSequentialOperation()) + { + _logger.LogDebug("Process sequentially."); + try + { + foreach (var item in workItems) + { + await ProcessItem(item).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // operation is cancelled. Do nothing. + } + + _logger.LogDebug("Process sequentially done."); + return; + } + + for (var i = 0; i < workItems.Length; i++) + { + var item = workItems[i]!; + _tasks.Add(item, CancellationToken.None); + } + + if (_deadlockDetector.Value is not null) + { + _logger.LogDebug("Nested invocation detected, process in-place."); + try + { + // we are in a nested loop. There is no reason to spawn a task here as that would just lead to deadlocks and no additional concurrency is achieved + while (workItems.Any(e => !e.Done.Task.IsCompleted) && _tasks.TryTake(out var item, 200, _deadlockDetector.Value.Token)) + { + await ProcessItem(item).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (_deadlockDetector.Value.IsCancellationRequested) + { + // operation is cancelled. Do nothing. + } + + _logger.LogDebug("process in-place done."); + } + else + { + Worker(); + _logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length); + await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false); + _logger.LogDebug("{NoWorkers} completed.", workItems.Length); + ScheduleTaskCleanup(); + } + } + + /// <inheritdoc/> + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + _tasks.CompleteAdding(); + foreach (var item in _taskRunners) + { + await item.Key.CancelAsync().ConfigureAwait(false); + } + + _tasks.Dispose(); + if (_cleanupTask is not null) + { + await _cleanupTask.ConfigureAwait(false); + _cleanupTask?.Dispose(); + } + } + + private class TaskQueueItem + { + public required object Data { get; init; } + + public double ProgressValue { get; set; } + + public required Func<object, Task> Worker { get; init; } + + public required IProgress<double> Progress { get; init; } + + public TaskCompletionSource Done { get; } = new(); + + public CancellationToken CancellationToken { get; init; } + } +} diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 364470cd2..8d3977103 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -230,10 +230,10 @@ namespace MediaBrowser.Controller.MediaEncoding { var hwType = encodingOptions.HardwareAccelerationType; - // Only Intel has VA-API MJPEG encoder + // Only enable VA-API MJPEG encoder on Intel iHD driver. + // Legacy platforms supported ONLY by i965 do not support MJPEG encoder. if (hwType == HardwareAccelerationType.vaapi - && !(_mediaEncoder.IsVaapiDeviceInteliHD - || _mediaEncoder.IsVaapiDeviceInteli965)) + && !_mediaEncoder.IsVaapiDeviceInteliHD) { return _defaultMjpegEncoder; } @@ -2376,6 +2376,13 @@ namespace MediaBrowser.Controller.MediaEncoding var requestHasHDR10 = requestedRangeTypes.Contains(VideoRangeType.HDR10.ToString(), StringComparison.OrdinalIgnoreCase); var requestHasHLG = requestedRangeTypes.Contains(VideoRangeType.HLG.ToString(), StringComparison.OrdinalIgnoreCase); var requestHasSDR = requestedRangeTypes.Contains(VideoRangeType.SDR.ToString(), StringComparison.OrdinalIgnoreCase); + var requestHasDOVI = requestedRangeTypes.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase); + + // If the client does not support DOVI and the video stream is DOVI without fallback, we should not copy it. + if (!requestHasDOVI && videoStream.VideoRangeType == VideoRangeType.DOVI) + { + return false; + } if (!requestedRangeTypes.Contains(videoStream.VideoRangeType.ToString(), StringComparison.OrdinalIgnoreCase) && !((requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10) @@ -2383,6 +2390,12 @@ namespace MediaBrowser.Controller.MediaEncoding || (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR) || (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus))) { + // If the video stream is in a static HDR format, don't allow copy if the client does not support HDR10 or HLG. + if (videoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG) + { + return false; + } + // Check complicated cases where we need to remove dynamic metadata // Conservatively refuse to copy if the encoder can't remove dynamic metadata, // but a removal is required for compatability reasons. @@ -4435,6 +4448,13 @@ namespace MediaBrowser.Controller.MediaEncoding var swapOutputWandH = doVppTranspose && swapWAndH; var hwScaleFilter = GetHwScaleFilter("vpp", "qsv", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + // d3d11va doesn't support dynamic pool size, use vpp filter ctx to relay + // to prevent encoder async and bframes from exhausting the decoder pool. + if (!string.IsNullOrEmpty(hwScaleFilter) && isD3d11vaDecoder) + { + hwScaleFilter += ":passthrough=0"; + } + if (!string.IsNullOrEmpty(hwScaleFilter) && doVppTranspose) { hwScaleFilter += $":transpose={transposeDir}"; @@ -7131,7 +7151,8 @@ namespace MediaBrowser.Controller.MediaEncoding inputModifier += " -async " + state.InputAudioSync; } - if (!string.IsNullOrEmpty(state.InputVideoSync)) + // The -fps_mode option cannot be applied to input + if (!string.IsNullOrEmpty(state.InputVideoSync) && _mediaEncoder.EncoderVersion < new Version(5, 1)) { inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion); } diff --git a/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs b/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs index 720c607f1..4f13a7ecc 100644 --- a/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs +++ b/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs @@ -20,10 +20,10 @@ public interface IMediaSegmentManager /// </summary> /// <param name="baseItem">The Item to evaluate.</param> /// <param name="libraryOptions">The library options.</param> - /// <param name="overwrite">If set, will remove existing segments and replace it with new ones otherwise will check for existing segments and if found any, stops.</param> + /// <param name="forceOverwrite">If set, will force to remove existing segments and replace it with new ones otherwise will check for existing segments and if found any that should not be deleted, stops.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>A task that indicates the Operation is finished.</returns> - Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool overwrite, CancellationToken cancellationToken); + Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken); /// <summary> /// Returns if this item supports media segments. diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index e185898bf..f4ac0ece4 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; @@ -102,4 +103,11 @@ public interface IItemRepository IReadOnlyList<string> GetGenreNames(); IReadOnlyList<string> GetAllArtistNames(); + + /// <summary> + /// Checks if an item has been persisted to the database. + /// </summary> + /// <param name="id">The id to check.</param> + /// <returns>True if the item exists, otherwise false.</returns> + Task<bool> ItemExistsAsync(Guid id); } diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index c25adb774..025a81524 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -235,11 +235,11 @@ namespace MediaBrowser.LocalMetadata.Savers { if (item is Person) { - await writer.WriteElementStringAsync(null, "DeathDate", null, item.EndDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false); + await writer.WriteElementStringAsync(null, "DeathDate", null, item.EndDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false); } else if (item is not Episode) { - await writer.WriteElementStringAsync(null, "EndDate", null, item.EndDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false); + await writer.WriteElementStringAsync(null, "EndDate", null, item.EndDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false); } } diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 2eb647e26..237b537bc 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -827,7 +827,7 @@ namespace MediaBrowser.MediaEncoding.Encoder } /// <inheritdoc /> - public Task<string> ExtractVideoImagesOnIntervalAccelerated( + public async Task<string> ExtractVideoImagesOnIntervalAccelerated( string inputFile, string container, MediaSourceInfo mediaSource, @@ -918,18 +918,34 @@ namespace MediaBrowser.MediaEncoding.Encoder inputArg = "-hwaccel_flags +low_priority " + inputArg; } - if (enableKeyFrameOnlyExtraction) - { - inputArg = "-skip_frame nokey " + inputArg; - } - var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, vidEncoder).Trim(); if (string.IsNullOrWhiteSpace(filterParam)) { throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters."); } - return ExtractVideoImagesOnIntervalInternal(inputArg, filterParam, vidEncoder, threads, qualityScale, priority, cancellationToken); + try + { + return await ExtractVideoImagesOnIntervalInternal( + (enableKeyFrameOnlyExtraction ? "-skip_frame nokey " : string.Empty) + inputArg, + filterParam, + vidEncoder, + threads, + qualityScale, + priority, + cancellationToken).ConfigureAwait(false); + } + catch (FfmpegException ex) + { + if (!enableKeyFrameOnlyExtraction) + { + throw; + } + + _logger.LogWarning(ex, "I-frame trickplay extraction failed, will attempt standard way. Input: {InputFile}", inputFile); + } + + return await ExtractVideoImagesOnIntervalInternal(inputArg, filterParam, vidEncoder, threads, qualityScale, priority, cancellationToken).ConfigureAwait(false); } private async Task<string> ExtractVideoImagesOnIntervalInternal( @@ -1071,11 +1087,8 @@ namespace MediaBrowser.MediaEncoding.Encoder } } - var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1; - - if (exitCode == -1) + if (!ranToCompletion || processWrapper.ExitCode != 0) { - _logger.LogError("ffmpeg image extraction failed for {ProcessDescription}", processDescription); // Cleanup temp folder here, because the targetDirectory is not returned and the cleanup for failed ffmpeg process is not possible for caller. // Ideally the ffmpeg should not write any files if it fails, but it seems like it is not guaranteed. try diff --git a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs index 1a636b240..88c378d66 100644 --- a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs +++ b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs @@ -43,7 +43,12 @@ namespace MediaBrowser.Model.Dlna } } - var referenceBitrate = h264EquivalentOutputBitrate * (30.0f / (targetFps ?? 30.0f)); + // Our reference bitrate is based on SDR h264 at 30fps + var referenceFps = targetFps ?? 30.0f; + var referenceScale = referenceFps <= 30.0f + ? 30.0f / referenceFps + : 1.0f / MathF.Sqrt(referenceFps / 30.0f); + var referenceBitrate = h264EquivalentOutputBitrate * referenceScale; if (isHdr) { diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index 937409111..8f223c12a 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -569,7 +569,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the trickplay manifest. /// </summary> /// <value>The trickplay manifest.</value> - public Dictionary<string, Dictionary<int, TrickplayInfo>> Trickplay { get; set; } + public Dictionary<string, Dictionary<int, TrickplayInfoDto>> Trickplay { get; set; } /// <summary> /// Gets or sets the type of the location. diff --git a/MediaBrowser.Model/Dto/TrickplayInfoDto.cs b/MediaBrowser.Model/Dto/TrickplayInfoDto.cs new file mode 100644 index 000000000..0c5f6e817 --- /dev/null +++ b/MediaBrowser.Model/Dto/TrickplayInfoDto.cs @@ -0,0 +1,62 @@ +using System; +using Jellyfin.Database.Implementations.Entities; + +namespace MediaBrowser.Model.Dto; + +/// <summary> +/// The trickplay api model. +/// </summary> +public record TrickplayInfoDto +{ + /// <summary> + /// Initializes a new instance of the <see cref="TrickplayInfoDto"/> class. + /// </summary> + /// <param name="info">The trickplay info.</param> + public TrickplayInfoDto(TrickplayInfo info) + { + ArgumentNullException.ThrowIfNull(info); + + Width = info.Width; + Height = info.Height; + TileWidth = info.TileWidth; + TileHeight = info.TileHeight; + ThumbnailCount = info.ThumbnailCount; + Interval = info.Interval; + Bandwidth = info.Bandwidth; + } + + /// <summary> + /// Gets the width of an individual thumbnail. + /// </summary> + public int Width { get; init; } + + /// <summary> + /// Gets the height of an individual thumbnail. + /// </summary> + public int Height { get; init; } + + /// <summary> + /// Gets the amount of thumbnails per row. + /// </summary> + public int TileWidth { get; init; } + + /// <summary> + /// Gets the amount of thumbnails per column. + /// </summary> + public int TileHeight { get; init; } + + /// <summary> + /// Gets the total amount of non-black thumbnails. + /// </summary> + public int ThumbnailCount { get; init; } + + /// <summary> + /// Gets the interval in milliseconds between each trickplay thumbnail. + /// </summary> + public int Interval { get; init; } + + /// <summary> + /// Gets the peak bandwidth usage in bits per second. + /// </summary> + public int Bandwidth { get; init; } +} diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index 5c8f37fcd..b1626e2c9 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -273,11 +273,28 @@ namespace MediaBrowser.Model.Entities // Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded). if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase)) { - // Get full language string i.e. eng -> English. - string fullLanguage = CultureInfo - .GetCultures(CultureTypes.NeutralCultures) - .FirstOrDefault(r => r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase)) - ?.DisplayName; + // Get full language string i.e. eng -> English, zh-Hans -> Chinese (Simplified). + var cultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures); + CultureInfo match = null; + if (Language.Contains('-', StringComparison.OrdinalIgnoreCase)) + { + match = cultures.FirstOrDefault(r => + r.Name.Equals(Language, StringComparison.OrdinalIgnoreCase)); + + if (match is null) + { + string baseLang = Language.AsSpan().LeftPart('-').ToString(); + match = cultures.FirstOrDefault(r => + r.TwoLetterISOLanguageName.Equals(baseLang, StringComparison.OrdinalIgnoreCase)); + } + } + else + { + match = cultures.FirstOrDefault(r => + r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase)); + } + + string fullLanguage = match?.DisplayName; attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language)); } @@ -376,11 +393,28 @@ namespace MediaBrowser.Model.Entities if (!string.IsNullOrEmpty(Language)) { - // Get full language string i.e. eng -> English. - string fullLanguage = CultureInfo - .GetCultures(CultureTypes.NeutralCultures) - .FirstOrDefault(r => r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase)) - ?.DisplayName; + // Get full language string i.e. eng -> English, zh-Hans -> Chinese (Simplified). + var cultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures); + CultureInfo match = null; + if (Language.Contains('-', StringComparison.OrdinalIgnoreCase)) + { + match = cultures.FirstOrDefault(r => + r.Name.Equals(Language, StringComparison.OrdinalIgnoreCase)); + + if (match is null) + { + string baseLang = Language.AsSpan().LeftPart('-').ToString(); + match = cultures.FirstOrDefault(r => + r.TwoLetterISOLanguageName.Equals(baseLang, StringComparison.OrdinalIgnoreCase)); + } + } + else + { + match = cultures.FirstOrDefault(r => + r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase)); + } + + string fullLanguage = match?.DisplayName; attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language)); } else diff --git a/MediaBrowser.Model/Lyrics/LyricLineCue.cs b/MediaBrowser.Model/Lyrics/LyricLineCue.cs index 1172a0231..291553361 100644 --- a/MediaBrowser.Model/Lyrics/LyricLineCue.cs +++ b/MediaBrowser.Model/Lyrics/LyricLineCue.cs @@ -8,22 +8,29 @@ public class LyricLineCue /// <summary> /// Initializes a new instance of the <see cref="LyricLineCue"/> class. /// </summary> - /// <param name="position">The start of the character index of the lyric.</param> + /// <param name="position">The start character index of the cue.</param> + /// <param name="endPosition">The end character index of the cue.</param> /// <param name="start">The start of the timestamp the lyric is synced to in ticks.</param> /// <param name="end">The end of the timestamp the lyric is synced to in ticks.</param> - public LyricLineCue(int position, long start, long? end) + public LyricLineCue(int position, int endPosition, long start, long? end) { Position = position; + EndPosition = endPosition; Start = start; End = end; } /// <summary> - /// Gets the character index of the lyric. + /// Gets the start character index of the cue. /// </summary> public int Position { get; } /// <summary> + /// Gets the end character index of the cue. + /// </summary> + public int EndPosition { get; } + + /// <summary> /// Gets the timestamp the lyric is synced to in ticks. /// </summary> public long Start { get; } diff --git a/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs b/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs index 8c1f44de8..53d017375 100644 --- a/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs +++ b/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Model.MediaSegments; namespace MediaBrowser.Model; @@ -11,4 +14,9 @@ public record MediaSegmentGenerationRequest /// Gets the Id to the BaseItem the segments should be extracted from. /// </summary> public Guid ItemId { get; init; } + + /// <summary> + /// Gets existing media segments generated on an earlier scan by this provider. + /// </summary> + public required IReadOnlyList<MediaSegmentDto> ExistingSegments { get; init; } } diff --git a/MediaBrowser.Model/Net/MimeTypes.cs b/MediaBrowser.Model/Net/MimeTypes.cs index de087d0e7..79f8675cb 100644 --- a/MediaBrowser.Model/Net/MimeTypes.cs +++ b/MediaBrowser.Model/Net/MimeTypes.cs @@ -56,6 +56,7 @@ namespace MediaBrowser.Model.Net ".rec", ".ts", ".rmvb", + ".vob", ".webm", ".wmv", ".wtv", diff --git a/MediaBrowser.Model/Session/QueueItem.cs b/MediaBrowser.Model/Session/QueueItem.cs index 32b19101b..43920a846 100644 --- a/MediaBrowser.Model/Session/QueueItem.cs +++ b/MediaBrowser.Model/Session/QueueItem.cs @@ -3,12 +3,11 @@ using System; -namespace MediaBrowser.Model.Session +namespace MediaBrowser.Model.Session; + +public record QueueItem { - public class QueueItem - { - public Guid Id { get; set; } + public Guid Id { get; set; } - public string PlaylistItemId { get; set; } - } + public string PlaylistItemId { get; set; } } diff --git a/MediaBrowser.Providers/Books/AudioBookMetadataService.cs b/MediaBrowser.Providers/Books/AudioBookMetadataService.cs index 1b2deaef3..845f53493 100644 --- a/MediaBrowser.Providers/Books/AudioBookMetadataService.cs +++ b/MediaBrowser.Providers/Books/AudioBookMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -24,14 +25,16 @@ public class AudioBookMetadataService : MetadataService<AudioBook, SongInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public AudioBookMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<AudioBookMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/Books/BookMetadataService.cs b/MediaBrowser.Providers/Books/BookMetadataService.cs index 97cd04b45..3b8dbfa7b 100644 --- a/MediaBrowser.Providers/Books/BookMetadataService.cs +++ b/MediaBrowser.Providers/Books/BookMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -24,14 +25,16 @@ public class BookMetadataService : MetadataService<Book, BookInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public BookMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<BookMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs index ab3cd9483..1cb6bf234 100644 --- a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs +++ b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs @@ -5,6 +5,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -27,14 +28,16 @@ public class BoxSetMetadataService : MetadataService<BoxSet, BoxSetInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public BoxSetMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<BoxSetMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/Channels/ChannelMetadataService.cs b/MediaBrowser.Providers/Channels/ChannelMetadataService.cs index 1285284aa..7e9b694b3 100644 --- a/MediaBrowser.Providers/Channels/ChannelMetadataService.cs +++ b/MediaBrowser.Providers/Channels/ChannelMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class ChannelMetadataService : MetadataService<Channel, ItemLookupInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public ChannelMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<ChannelMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } } diff --git a/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs b/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs index 372b08090..9efef60a1 100644 --- a/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs +++ b/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class CollectionFolderMetadataService : MetadataService<CollectionFolder, /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public CollectionFolderMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<CollectionFolderMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } } diff --git a/MediaBrowser.Providers/Folders/FolderMetadataService.cs b/MediaBrowser.Providers/Folders/FolderMetadataService.cs index 9ffb33abe..272bb31e3 100644 --- a/MediaBrowser.Providers/Folders/FolderMetadataService.cs +++ b/MediaBrowser.Providers/Folders/FolderMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class FolderMetadataService : MetadataService<Folder, ItemLookupInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public FolderMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<FolderMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/Folders/UserViewMetadataService.cs b/MediaBrowser.Providers/Folders/UserViewMetadataService.cs index d0171aa8c..ab4bc917d 100644 --- a/MediaBrowser.Providers/Folders/UserViewMetadataService.cs +++ b/MediaBrowser.Providers/Folders/UserViewMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class UserViewMetadataService : MetadataService<UserView, ItemLookupInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public UserViewMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<UserViewMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } } diff --git a/MediaBrowser.Providers/Genres/GenreMetadataService.cs b/MediaBrowser.Providers/Genres/GenreMetadataService.cs index 23aaf4c92..0dd0384dc 100644 --- a/MediaBrowser.Providers/Genres/GenreMetadataService.cs +++ b/MediaBrowser.Providers/Genres/GenreMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class GenreMetadataService : MetadataService<Genre, ItemLookupInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public GenreMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<GenreMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } } diff --git a/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs b/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs index c5c46f64c..83f9984ea 100644 --- a/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs +++ b/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class LiveTvMetadataService : MetadataService<LiveTvChannel, ItemLookupIn /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public LiveTvMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<LiveTvMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } } diff --git a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs index 27d17b535..fa711eb28 100644 --- a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs +++ b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using Jellyfin.Extensions; using LrcParser.Model; @@ -66,47 +67,56 @@ public partial class LrcLyricParser : ILyricParser } List<LyricLine> lyricList = []; - for (var l = 0; l < sortedLyricData.Count; l++) + for (var lineIndex = 0; lineIndex < sortedLyricData.Count; lineIndex++) { - var cues = new List<LyricLineCue>(); - var lyric = sortedLyricData[l]; + var lyric = sortedLyricData[lineIndex]; - if (lyric.TimeTags.Count != 0) + // Extract cues from time tags + var cues = new List<LyricLineCue>(); + if (lyric.TimeTags.Count > 0) { var keys = lyric.TimeTags.Keys.ToList(); - int current = 0, next = 1; - while (next < keys.Count) + for (var tagIndex = 0; tagIndex < keys.Count - 1; tagIndex++) { - var currentKey = keys[current]; - var currentMs = lyric.TimeTags[currentKey] ?? 0; - var nextMs = lyric.TimeTags[keys[next]] ?? 0; - - cues.Add(new LyricLineCue( - position: Math.Max(currentKey.Index, 0), - start: TimeSpan.FromMilliseconds(currentMs).Ticks, - end: TimeSpan.FromMilliseconds(nextMs).Ticks)); + var currentKey = keys[tagIndex]; + var nextKey = keys[tagIndex + 1]; - current++; - next++; + var currentPos = currentKey.State == IndexState.End ? currentKey.Index + 1 : currentKey.Index; + var nextPos = nextKey.State == IndexState.End ? nextKey.Index + 1 : nextKey.Index; + var currentMs = lyric.TimeTags[currentKey] ?? 0; + var nextMs = lyric.TimeTags[keys[tagIndex + 1]] ?? 0; + var currentSlice = lyric.Text[currentPos..nextPos]; + var currentSliceTrimmed = currentSlice.Trim(); + if (currentSliceTrimmed.Length > 0) + { + cues.Add(new LyricLineCue( + position: currentPos, + endPosition: nextPos, + start: TimeSpan.FromMilliseconds(currentMs).Ticks, + end: TimeSpan.FromMilliseconds(nextMs).Ticks)); + } } - var lastKey = keys[current]; + var lastKey = keys[^1]; + var lastPos = lastKey.State == IndexState.End ? lastKey.Index + 1 : lastKey.Index; var lastMs = lyric.TimeTags[lastKey] ?? 0; + var lastSlice = lyric.Text[lastPos..]; + var lastSliceTrimmed = lastSlice.Trim(); - cues.Add(new LyricLineCue( - position: Math.Max(lastKey.Index, 0), - start: TimeSpan.FromMilliseconds(lastMs).Ticks, - end: l + 1 < sortedLyricData.Count ? TimeSpan.FromMilliseconds(sortedLyricData[l + 1].StartTime).Ticks : null)); + if (lastSliceTrimmed.Length > 0) + { + cues.Add(new LyricLineCue( + position: lastPos, + endPosition: lyric.Text.Length, + start: TimeSpan.FromMilliseconds(lastMs).Ticks, + end: lineIndex + 1 < sortedLyricData.Count ? TimeSpan.FromMilliseconds(sortedLyricData[lineIndex + 1].StartTime).Ticks : null)); + } } long lyricStartTicks = TimeSpan.FromMilliseconds(lyric.StartTime).Ticks; - lyricList.Add(new LyricLine(WhitespaceRegex().Replace(lyric.Text.Trim(), " "), lyricStartTicks, cues)); + lyricList.Add(new LyricLine(lyric.Text, lyricStartTicks, cues)); } return new LyricDto { Lyrics = lyricList }; } - - // Replacement is required until https://github.com/karaoke-dev/LrcParser/issues/83 is resolved. - [GeneratedRegex(@"\s+")] - private static partial Regex WhitespaceRegex(); } diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index ee22b4bc6..75882a088 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -377,6 +377,10 @@ namespace MediaBrowser.Providers.Manager { // Nothing to do, already gone } + catch (DirectoryNotFoundException) + { + // Nothing to do, already gone + } catch (UnauthorizedAccessException ex) { _logger.LogWarning(ex, "Unable to delete {Image}", image.Path); diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 8eae6af72..0f2188aa8 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -14,6 +14,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -33,7 +34,8 @@ namespace MediaBrowser.Providers.Manager IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) { ServerConfigurationManager = serverConfigurationManager; Logger = logger; @@ -41,6 +43,7 @@ namespace MediaBrowser.Providers.Manager FileSystem = fileSystem; LibraryManager = libraryManager; ExternalDataManager = externalDataManager; + ItemRepository = itemRepository; ImageProvider = new ItemImageProvider(Logger, ProviderManager, FileSystem); } @@ -58,6 +61,8 @@ namespace MediaBrowser.Providers.Manager protected IExternalDataManager ExternalDataManager { get; } + protected IItemRepository ItemRepository { get; } + protected virtual bool EnableUpdatingPremiereDateFromChildren => false; protected virtual bool EnableUpdatingGenresFromChildren => false; @@ -68,11 +73,11 @@ namespace MediaBrowser.Providers.Manager public virtual int Order => 0; - private FileSystemMetadata TryGetFile(string path, IDirectoryService directoryService) + private FileSystemMetadata TryGetFileSystemMetadata(string path, IDirectoryService directoryService) { try { - return directoryService.GetFile(path); + return directoryService.GetFileSystemEntry(path); } catch (Exception ex) { @@ -85,8 +90,9 @@ namespace MediaBrowser.Providers.Manager { var itemOfType = (TItemType)item; var updateType = ItemUpdateType.None; + var libraryOptions = LibraryManager.GetLibraryOptions(item); - var isFirstRefresh = item.DateLastRefreshed.Date == DateTime.MinValue.Date; + var isFirstRefresh = item.DateLastRefreshed == DateTime.MinValue; var hasRefreshedMetadata = true; var hasRefreshedImages = true; @@ -141,7 +147,8 @@ namespace MediaBrowser.Providers.Manager Item = itemOfType }; - var beforeSaveResult = BeforeSave(itemOfType, isFirstRefresh || refreshOptions.ReplaceAllMetadata || refreshOptions.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || requiresRefresh || refreshOptions.ForceSave, updateType); + var beforeSaveResult = await BeforeSave(itemOfType, isFirstRefresh || refreshOptions.ReplaceAllMetadata || refreshOptions.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || requiresRefresh || refreshOptions.ForceSave, updateType) + .ConfigureAwait(false); updateType |= beforeSaveResult; updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false); @@ -218,7 +225,7 @@ namespace MediaBrowser.Providers.Manager { if (item.IsFileProtocol) { - var file = TryGetFile(item.Path, refreshOptions.DirectoryService); + var file = TryGetFileSystemMetadata(item.Path, refreshOptions.DirectoryService); if (file is not null) { item.DateModified = file.LastWriteTimeUtc; @@ -262,14 +269,13 @@ namespace MediaBrowser.Providers.Manager protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken) { + await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false); if (result.Item.SupportsPeople && result.People is not null) { var baseItem = result.Item; await LibraryManager.UpdatePeopleAsync(baseItem, result.People, cancellationToken).ConfigureAwait(false); } - - await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false); } protected virtual Task AfterMetadataRefresh(TItemType item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken) @@ -285,12 +291,20 @@ namespace MediaBrowser.Providers.Manager /// <param name="isFullRefresh">if set to <c>true</c> [is full refresh].</param> /// <param name="currentUpdateType">Type of the current update.</param> /// <returns>ItemUpdateType.</returns> - private ItemUpdateType BeforeSave(TItemType item, bool isFullRefresh, ItemUpdateType currentUpdateType) + private async Task<ItemUpdateType> BeforeSave(TItemType item, bool isFullRefresh, ItemUpdateType currentUpdateType) { var updateType = BeforeSaveInternal(item, isFullRefresh, currentUpdateType); updateType |= item.OnMetadataChanged(); + if (updateType == ItemUpdateType.None) + { + if (!await ItemRepository.ItemExistsAsync(item.Id).ConfigureAwait(false)) + { + return ItemUpdateType.MetadataImport; + } + } + return updateType; } @@ -318,13 +332,11 @@ namespace MediaBrowser.Providers.Manager if (!string.IsNullOrEmpty(itemPath)) { var info = FileSystem.GetFileSystemInfo(itemPath); - var modificationDate = info.LastWriteTimeUtc; - var itemLastModifiedFileSystem = item.DateModified; - if (info.Exists && itemLastModifiedFileSystem != modificationDate) + if (info.Exists && item.HasChanged(info.LastWriteTimeUtc)) { - Logger.LogDebug("File modification time changed from {Then} to {Now}: {Path}", itemLastModifiedFileSystem, modificationDate, itemPath); + Logger.LogDebug("File modification time changed from {Then} to {Now}: {Path}", item.DateModified, info.LastWriteTimeUtc, itemPath); - item.DateModified = modificationDate; + item.DateModified = info.LastWriteTimeUtc; if (ServerConfigurationManager.GetMetadataConfiguration().UseFileCreationTimeForDateAdded) { item.DateCreated = info.CreationTimeUtc; @@ -817,7 +829,9 @@ namespace MediaBrowser.Providers.Manager } else { - var shouldReplace = options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly || options.ReplaceAllMetadata; + var shouldReplace = (options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly && options.ReplaceAllMetadata) + // Case for Scan for new and updated files + || (options.MetadataRefreshMode == MetadataRefreshMode.Default && !options.ReplaceAllMetadata); MergeData(temp, metadata, item.LockedFields, shouldReplace, true); } } @@ -1164,12 +1178,12 @@ namespace MediaBrowser.Providers.Manager target.LockedFields = target.LockedFields.Concat(source.LockedFields).Distinct().ToArray(); } - if (source.DateCreated != default) + if (source.DateCreated != DateTime.MinValue) { target.DateCreated = source.DateCreated; } - if (replaceData || source.DateModified != default) + if (replaceData || source.DateModified != DateTime.MinValue) { target.DateModified = source.DateModified; } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 1a29548f2..43f0746ba 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -669,8 +669,13 @@ namespace MediaBrowser.Providers.Manager private async Task SaveMetadataAsync(BaseItem item, ItemUpdateType updateType, IEnumerable<IMetadataSaver> savers) { var libraryOptions = _libraryManager.GetLibraryOptions(item); + var applicableSavers = savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, updateType, false)).ToList(); + if (applicableSavers.Count == 0) + { + return; + } - foreach (var saver in savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, updateType, false))) + foreach (var saver in applicableSavers) { _logger.LogDebug("Saving {Item} to {Saver}", item.Path ?? item.Name, saver.Name); @@ -692,6 +697,7 @@ namespace MediaBrowser.Providers.Manager { _libraryMonitor.ReportFileSystemChangeBeginning(path); await saver.SaveAsync(item, CancellationToken.None).ConfigureAwait(false); + item.DateLastSaved = DateTime.UtcNow; } catch (Exception ex) { @@ -707,6 +713,7 @@ namespace MediaBrowser.Providers.Manager try { await saver.SaveAsync(item, CancellationToken.None).ConfigureAwait(false); + item.DateLastSaved = DateTime.UtcNow; } catch (Exception ex) { @@ -714,6 +721,8 @@ namespace MediaBrowser.Providers.Manager } } } + + _libraryManager.CreateItem(item, null); } /// <summary> diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index cbbb7e83e..c0680b901 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -192,7 +192,20 @@ namespace MediaBrowser.Providers.MediaInfo if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast)) { var people = new List<PersonInfo>(); - var albumArtists = string.IsNullOrEmpty(trackAlbumArtist) ? [] : trackAlbumArtist.Split(InternalValueSeparator); + string[]? albumArtists = null; + if (libraryOptions.PreferNonstandardArtistsTag) + { + TryGetSanitizedAdditionalFields(track, "ALBUMARTISTS", out var albumArtistsTagString); + if (albumArtistsTagString is not null) + { + albumArtists = albumArtistsTagString.Split(InternalValueSeparator); + } + } + + if (albumArtists is null || albumArtists.Length == 0) + { + albumArtists = string.IsNullOrEmpty(trackAlbumArtist) ? [] : trackAlbumArtist.Split(InternalValueSeparator); + } if (libraryOptions.UseCustomTagDelimiters) { @@ -205,7 +218,7 @@ namespace MediaBrowser.Providers.MediaInfo { PeopleHelper.AddPerson(people, new PersonInfo { - Name = albumArtist.Trim(), + Name = albumArtist, Type = PersonKind.AlbumArtist }); } @@ -237,7 +250,7 @@ namespace MediaBrowser.Providers.MediaInfo { PeopleHelper.AddPerson(people, new PersonInfo { - Name = performer.Trim(), + Name = performer, Type = PersonKind.Artist }); } @@ -251,7 +264,7 @@ namespace MediaBrowser.Providers.MediaInfo { PeopleHelper.AddPerson(people, new PersonInfo { - Name = composer.Trim(), + Name = composer, Type = PersonKind.Composer }); } @@ -340,9 +353,10 @@ namespace MediaBrowser.Providers.MediaInfo genres = genres.Trimmed().Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - audio.Genres = options.ReplaceAllMetadata || audio.Genres is null || audio.Genres.Length == 0 - ? genres - : audio.Genres; + if (options.ReplaceAllMetadata || audio.Genres is null || audio.Genres.Length == 0 || audio.Genres.All(string.IsNullOrWhiteSpace)) + { + audio.Genres = genres; + } } TryGetSanitizedAdditionalFields(track, "REPLAYGAIN_TRACK_GAIN", out var trackGainTag); @@ -435,7 +449,11 @@ namespace MediaBrowser.Providers.MediaInfo // Save extracted lyrics if they exist, // and if the audio doesn't yet have lyrics. - var lyrics = track.Lyrics.SynchronizedLyrics.Count > 0 ? track.Lyrics.FormatSynchToLRC() : track.Lyrics.UnsynchronizedLyrics; + // ATL supports both SRT and LRC formats as synchronized lyrics, but we only want to save LRC format. + var supportedLyrics = track.Lyrics.Where(l => l.Format != LyricsInfo.LyricsFormat.SRT).ToList(); + var candidateSynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is not LyricsInfo.LyricsFormat.UNSYNCHRONIZED and not LyricsInfo.LyricsFormat.OTHER && l.SynchronizedLyrics is not null); + var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.UNSYNCHRONIZED or LyricsInfo.LyricsFormat.OTHER && l.UnsynchronizedLyrics is not null); + var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUnsynchronizedLyric?.UnsynchronizedLyrics; if (!string.IsNullOrWhiteSpace(lyrics) && tryExtractEmbeddedLyrics) { diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index d85f49b1d..bdb6b93be 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Chapters; @@ -275,10 +276,7 @@ namespace MediaBrowser.Providers.MediaInfo _mediaStreamRepository.SaveMediaStreams(video.Id, mediaStreams, cancellationToken); - if (mediaAttachments.Any()) - { - _mediaAttachmentRepository.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken); - } + _mediaAttachmentRepository.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken); if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || options.MetadataRefreshMode == MetadataRefreshMode.Default) @@ -516,12 +514,15 @@ namespace MediaBrowser.Providers.MediaInfo foreach (var person in data.People) { - PeopleHelper.AddPerson(people, new PersonInfo + if (!string.IsNullOrWhiteSpace(person.Name)) { - Name = person.Name.Trim(), - Type = person.Type, - Role = person.Role.Trim() - }); + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = person.Name, + Type = person.Type, + Role = person.Role.Trim() + }); + } } _libraryManager.UpdatePeople(video, people); diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 8c673350d..bd6b36458 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -130,7 +130,7 @@ namespace MediaBrowser.Providers.MediaInfo if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol) { var file = directoryService.GetFile(path); - if (file is not null && file.LastWriteTimeUtc != item.DateModified && file.Length != item.Size) + if (file is not null && item.HasChanged(file.LastWriteTimeUtc) && file.Length != item.Size) { _logger.LogDebug("Refreshing {ItemPath} due to file system modification.", path); return true; diff --git a/MediaBrowser.Providers/Movies/MovieMetadataService.cs b/MediaBrowser.Providers/Movies/MovieMetadataService.cs index a6e1f424d..8c169a7b6 100644 --- a/MediaBrowser.Providers/Movies/MovieMetadataService.cs +++ b/MediaBrowser.Providers/Movies/MovieMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -24,14 +25,16 @@ public class MovieMetadataService : MetadataService<Movie, MovieInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public MovieMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<MovieMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs index 7f38861e3..fa2442932 100644 --- a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs +++ b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs @@ -3,6 +3,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -25,14 +26,16 @@ public class TrailerMetadataService : MetadataService<Trailer, TrailerInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public TrailerMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<TrailerMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/Music/AlbumMetadataService.cs b/MediaBrowser.Providers/Music/AlbumMetadataService.cs index 2af3667d0..7c193b4d5 100644 --- a/MediaBrowser.Providers/Music/AlbumMetadataService.cs +++ b/MediaBrowser.Providers/Music/AlbumMetadataService.cs @@ -1,12 +1,15 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -29,14 +32,16 @@ public class AlbumMetadataService : MetadataService<MusicAlbum, AlbumInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public AlbumMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<AlbumMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } @@ -54,6 +59,16 @@ public class AlbumMetadataService : MetadataService<MusicAlbum, AlbumInfo> => item.GetRecursiveChildren(i => i is Audio); /// <inheritdoc /> + protected override Task AfterMetadataRefresh(MusicAlbum item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken) + { + base.AfterMetadataRefresh(item, refreshOptions, cancellationToken); + + SetPeople(item); + + return Task.CompletedTask; + } + + /// <inheritdoc /> protected override ItemUpdateType UpdateMetadataFromChildren(MusicAlbum item, IReadOnlyList<BaseItem> children, bool isFullRefresh, ItemUpdateType currentUpdateType) { var updateType = base.UpdateMetadataFromChildren(item, children, isFullRefresh, currentUpdateType); @@ -83,7 +98,6 @@ public class AlbumMetadataService : MetadataService<MusicAlbum, AlbumInfo> updateType |= SetArtistsFromSongs(item, songs); updateType |= SetAlbumArtistFromSongs(item, songs); updateType |= SetAlbumFromSongs(item, songs); - updateType |= SetPeople(item); } return updateType; @@ -178,37 +192,38 @@ public class AlbumMetadataService : MetadataService<MusicAlbum, AlbumInfo> } } - private ItemUpdateType SetPeople(MusicAlbum item) + private void SetPeople(MusicAlbum item) { - var updateType = ItemUpdateType.None; - if (item.AlbumArtists.Any() || item.Artists.Any()) { var people = new List<PersonInfo>(); foreach (var albumArtist in item.AlbumArtists) { - PeopleHelper.AddPerson(people, new PersonInfo + if (!string.IsNullOrWhiteSpace(albumArtist)) { - Name = albumArtist.Trim(), - Type = PersonKind.AlbumArtist - }); + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = albumArtist, + Type = PersonKind.AlbumArtist + }); + } } foreach (var artist in item.Artists) { - PeopleHelper.AddPerson(people, new PersonInfo + if (!string.IsNullOrWhiteSpace(artist)) { - Name = artist.Trim(), - Type = PersonKind.Artist - }); + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = artist, + Type = PersonKind.Artist + }); + } } LibraryManager.UpdatePeople(item, people); - updateType |= ItemUpdateType.MetadataEdit; } - - return updateType; } /// <inheritdoc /> diff --git a/MediaBrowser.Providers/Music/ArtistMetadataService.cs b/MediaBrowser.Providers/Music/ArtistMetadataService.cs index 9bac68627..22999077b 100644 --- a/MediaBrowser.Providers/Music/ArtistMetadataService.cs +++ b/MediaBrowser.Providers/Music/ArtistMetadataService.cs @@ -4,6 +4,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -25,14 +26,16 @@ public class ArtistMetadataService : MetadataService<MusicArtist, ArtistInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public ArtistMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<ArtistMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/Music/AudioMetadataService.cs b/MediaBrowser.Providers/Music/AudioMetadataService.cs index ccc1c90c0..f4d17686f 100644 --- a/MediaBrowser.Providers/Music/AudioMetadataService.cs +++ b/MediaBrowser.Providers/Music/AudioMetadataService.cs @@ -4,6 +4,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -26,14 +27,16 @@ public class AudioMetadataService : MetadataService<Audio, SongInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public AudioMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<AudioMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs index cde14539a..345e13460 100644 --- a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs +++ b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs @@ -4,6 +4,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -26,14 +27,16 @@ public class MusicVideoMetadataService : MetadataService<MusicVideo, MusicVideoI /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public MusicVideoMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<MusicVideoMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs b/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs index 201c0efcf..4b0044dcf 100644 --- a/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs +++ b/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class MusicGenreMetadataService : MetadataService<MusicGenre, ItemLookupI /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public MusicGenreMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<MusicGenreMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } } diff --git a/MediaBrowser.Providers/People/PersonMetadataService.cs b/MediaBrowser.Providers/People/PersonMetadataService.cs index d910327a3..23aff246e 100644 --- a/MediaBrowser.Providers/People/PersonMetadataService.cs +++ b/MediaBrowser.Providers/People/PersonMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class PersonMetadataService : MetadataService<Person, PersonLookupInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public PersonMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<PersonMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } } diff --git a/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs b/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs index 99c2c09c8..f05ed904f 100644 --- a/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs +++ b/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class PhotoAlbumMetadataService : MetadataService<PhotoAlbum, ItemLookupI /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public PhotoAlbumMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<PhotoAlbumMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } } diff --git a/MediaBrowser.Providers/Photos/PhotoMetadataService.cs b/MediaBrowser.Providers/Photos/PhotoMetadataService.cs index f90df8406..0f7a33560 100644 --- a/MediaBrowser.Providers/Photos/PhotoMetadataService.cs +++ b/MediaBrowser.Providers/Photos/PhotoMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class PhotoMetadataService : MetadataService<Photo, ItemLookupInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public PhotoMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<PhotoMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } } diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index a986b0b69..4c10fe3f1 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs @@ -215,7 +215,7 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol) { var file = directoryService.GetFile(path); - if (file is not null && file.LastWriteTimeUtc != item.DateModified) + if (file is not null && item.HasChanged(file.LastWriteTimeUtc)) { _logger.LogDebug("Refreshing {Path} due to date modified timestamp change.", path); return true; diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs index b9318f0c8..8df15e440 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs @@ -4,6 +4,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -27,14 +28,16 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public PlaylistMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<PlaylistMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs index ff30af879..49ece22a9 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs @@ -94,7 +94,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb if (!string.IsNullOrWhiteSpace(result.strArtist)) { - item.AlbumArtists = new string[] { result.strArtist }; + item.AlbumArtists = [result.strArtist]; } if (!string.IsNullOrEmpty(result.intYearReleased)) @@ -104,7 +104,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb if (!string.IsNullOrEmpty(result.strGenre)) { - item.Genres = new[] { result.strGenre }; + item.Genres = [result.strGenre]; } item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist); @@ -170,6 +170,11 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var url = AudioDbArtistProvider.BaseUrl + "/album-mb.php?i=" + musicBrainzReleaseGroupId; var path = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId); + var fileInfo = _fileSystem.GetFileSystemInfo(path); + if (fileInfo.Exists && (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2) + { + return; + } Directory.CreateDirectory(Path.GetDirectoryName(path)); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index 99b759ae2..f11b1d95a 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -39,6 +39,21 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public int MaxCastMembers { get; set; } = 15; /// <summary> + /// Gets or sets a value indicating the maximum number of crew members to fetch for an item. + /// </summary> + public int MaxCrewMembers { get; set; } = 15; + + /// <summary> + /// Gets or sets a value indicating whether to hide cast members without profile images. + /// </summary> + public bool HideMissingCastMembers { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to hide crew members without profile images. + /// </summary> + public bool HideMissingCrewMembers { get; set; } + + /// <summary> /// Gets or sets a value indicating the poster image size to fetch. /// </summary> public string? PosterSize { get; set; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index f3c24e7b4..89d380ec1 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -25,9 +25,24 @@ <input is="emby-checkbox" type="checkbox" id="importSeasonName" /> <span>Import season name from metadata fetched for series.</span> </label> - <div class="inputContainer"> - <input is="emby-input" type="number" id="maxCastMembers" pattern="[0-9]*" required min="0" max="1000" label="Max Cast Members" /> - <div class="fieldDescription">The maximum number of cast members to fetch for an item.</div> + <div class="verticalSection"> + <h2>Cast & Crew Settings</h2> + <div class="inputContainer"> + <input is="emby-input" type="number" id="maxCastMembers" pattern="[0-9]*" required min="0" max="1000" label="Max Cast Members" /> + <div class="fieldDescription">The maximum number of cast members to fetch for an item.</div> + </div> + <div class="inputContainer"> + <input is="emby-input" type="number" id="maxCrewMembers" pattern="[0-9]*" required min="0" max="1000" label="Max Crew Members" /> + <div class="fieldDescription">The maximum number of crew members to fetch for an item.</div> + </div> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="hideMissingCastMembers" /> + <span>Hide cast members without profile images.</span> + </label> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="hideMissingCrewMembers" /> + <span>Hide crew members without profile images.</span> + </label> </div> <div class="verticalSection verticalSection-extrabottompadding"> <h2>Image Scaling</h2> @@ -129,6 +144,8 @@ document.querySelector('#excludeTagsSeries').checked = config.ExcludeTagsSeries; document.querySelector('#excludeTagsMovies').checked = config.ExcludeTagsMovies; document.querySelector('#importSeasonName').checked = config.ImportSeasonName; + document.querySelector('#hideMissingCastMembers').checked = config.HideMissingCastMembers; + document.querySelector('#hideMissingCrewMembers').checked = config.HideMissingCrewMembers; var maxCastMembers = document.querySelector('#maxCastMembers'); maxCastMembers.value = config.MaxCastMembers; @@ -137,12 +154,18 @@ cancelable: false })); + var maxCrewMembers = document.querySelector('#maxCrewMembers'); + maxCrewMembers.value = config.MaxCrewMembers; + maxCrewMembers.dispatchEvent(new Event('change', { + bubbles: true, + cancelable: false + })); + pluginConfig = config; configureImageScaling(); }); }); - document.querySelector('.configForm') .addEventListener('submit', function (e) { Dashboard.showLoadingMsg(); @@ -153,6 +176,9 @@ config.ExcludeTagsMovies = document.querySelector('#excludeTagsMovies').checked; config.ImportSeasonName = document.querySelector('#importSeasonName').checked; config.MaxCastMembers = document.querySelector('#maxCastMembers').value; + config.MaxCrewMembers = document.querySelector('#maxCrewMembers').value; + config.HideMissingCastMembers = document.querySelector('#hideMissingCastMembers').checked; + config.HideMissingCrewMembers = document.querySelector('#hideMissingCrewMembers').checked; config.PosterSize = document.querySelector('#selectPosterSize').value; config.BackdropSize = document.querySelector('#selectBackdropSize').value; config.LogoSize = document.querySelector('#selectLogoSize').value; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 9bb6507fe..2f8cb68ef 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -144,6 +144,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies { var tmdbId = info.GetProviderId(MetadataProvider.Tmdb); var imdbId = info.GetProviderId(MetadataProvider.Imdb); + var config = Plugin.Instance.Configuration; if (string.IsNullOrEmpty(tmdbId) && string.IsNullOrEmpty(imdbId)) { @@ -249,12 +250,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (movieResult.Credits?.Cast is not null) { - foreach (var actor in movieResult.Credits.Cast.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers)) + var castQuery = movieResult.Credits.Cast.AsEnumerable(); + + if (config.HideMissingCastMembers) { + castQuery = castQuery.Where(a => !string.IsNullOrEmpty(a.ProfilePath)); + } + + castQuery = castQuery.OrderBy(a => a.Order).Take(config.MaxCastMembers); + + foreach (var actor in castQuery) + { + if (string.IsNullOrWhiteSpace(actor.Name)) + { + continue; + } + var personInfo = new PersonInfo { Name = actor.Name.Trim(), - Role = actor.Character.Trim(), + Role = actor.Character?.Trim() ?? string.Empty, Type = PersonKind.Actor, SortOrder = actor.Order }; @@ -275,32 +290,47 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (movieResult.Credits?.Crew is not null) { - foreach (var person in movieResult.Credits.Crew) + var crewQuery = movieResult.Credits.Crew + .Select(crewMember => new + { + CrewMember = crewMember, + PersonType = TmdbUtils.MapCrewToPersonType(crewMember) + }) + .Where(entry => + TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || + TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + + if (config.HideMissingCrewMembers) + { + crewQuery = crewQuery.Where(entry => !string.IsNullOrEmpty(entry.CrewMember.ProfilePath)); + } + + crewQuery = crewQuery.Take(config.MaxCrewMembers); + + foreach (var entry in crewQuery) { - // Normalize this - var type = TmdbUtils.MapCrewToPersonType(person); + var crewMember = entry.CrewMember; - if (!TmdbUtils.WantedCrewKinds.Contains(type) - && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrWhiteSpace(crewMember.Name)) { continue; } var personInfo = new PersonInfo { - Name = person.Name.Trim(), - Role = person.Job?.Trim(), - Type = type + Name = crewMember.Name.Trim(), + Role = crewMember.Job?.Trim() ?? string.Empty, + Type = entry.PersonType }; - if (!string.IsNullOrWhiteSpace(person.ProfilePath)) + if (!string.IsNullOrWhiteSpace(crewMember.ProfilePath)) { - personInfo.ImageUrl = _tmdbClientManager.GetProfileUrl(person.ProfilePath); + personInfo.ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath); } - if (person.Id > 0) + if (crewMember.Id > 0) { - personInfo.SetProviderId(MetadataProvider.Tmdb, person.Id.ToString(CultureInfo.InvariantCulture)); + personInfo.SetProviderId(MetadataProvider.Tmdb, crewMember.Id.ToString(CultureInfo.InvariantCulture)); } metadataResult.AddPerson(personInfo); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 73c3b4f16..7d0900cfd 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -81,6 +81,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) { var metadataResult = new MetadataResult<Episode>(); + var config = Plugin.Instance.Configuration; // Allowing this will dramatically increase scan times if (info.IsMissingEpisode) @@ -206,52 +207,106 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (credits?.Cast is not null) { - foreach (var actor in credits.Cast.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers)) + var castQuery = config.HideMissingCastMembers + ? credits.Cast.Where(a => !string.IsNullOrEmpty(a.ProfilePath)).OrderBy(a => a.Order) + : credits.Cast.OrderBy(a => a.Order); + + foreach (var actor in castQuery.Take(config.MaxCastMembers)) { - metadataResult.AddPerson(new PersonInfo + if (string.IsNullOrWhiteSpace(actor.Name)) + { + continue; + } + + var personInfo = new PersonInfo { Name = actor.Name.Trim(), - Role = actor.Character.Trim(), + Role = actor.Character?.Trim() ?? string.Empty, Type = PersonKind.Actor, - SortOrder = actor.Order - }); + SortOrder = actor.Order, + ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath) + }; + + if (actor.Id > 0) + { + personInfo.SetProviderId(MetadataProvider.Tmdb, actor.Id.ToString(CultureInfo.InvariantCulture)); + } + + metadataResult.AddPerson(personInfo); } } if (credits?.GuestStars is not null) { - foreach (var guest in credits.GuestStars.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers)) + var guestQuery = config.HideMissingCastMembers + ? credits.GuestStars.Where(a => !string.IsNullOrEmpty(a.ProfilePath)).OrderBy(a => a.Order) + : credits.GuestStars.OrderBy(a => a.Order); + + foreach (var guest in guestQuery.Take(config.MaxCastMembers)) { - metadataResult.AddPerson(new PersonInfo + if (string.IsNullOrWhiteSpace(guest.Name)) + { + continue; + } + + var personInfo = new PersonInfo { Name = guest.Name.Trim(), - Role = guest.Character.Trim(), + Role = guest.Character?.Trim() ?? string.Empty, Type = PersonKind.GuestStar, - SortOrder = guest.Order - }); + SortOrder = guest.Order, + ImageUrl = _tmdbClientManager.GetProfileUrl(guest.ProfilePath) + }; + + if (guest.Id > 0) + { + personInfo.SetProviderId(MetadataProvider.Tmdb, guest.Id.ToString(CultureInfo.InvariantCulture)); + } + + metadataResult.AddPerson(personInfo); } } - // and the rest from crew if (credits?.Crew is not null) { - foreach (var person in credits.Crew) + var crewQuery = credits.Crew + .Select(crewMember => new + { + CrewMember = crewMember, + PersonType = TmdbUtils.MapCrewToPersonType(crewMember) + }) + .Where(entry => + TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || + TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + + if (config.HideMissingCrewMembers) + { + crewQuery = crewQuery.Where(entry => !string.IsNullOrEmpty(entry.CrewMember.ProfilePath)); + } + + foreach (var entry in crewQuery.Take(config.MaxCrewMembers)) { - // Normalize this - var type = TmdbUtils.MapCrewToPersonType(person); + var crewMember = entry.CrewMember; - if (!TmdbUtils.WantedCrewKinds.Contains(type) - && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrWhiteSpace(crewMember.Name)) { continue; } - metadataResult.AddPerson(new PersonInfo + var personInfo = new PersonInfo { - Name = person.Name.Trim(), - Role = person.Job?.Trim(), - Type = type - }); + Name = crewMember.Name.Trim(), + Role = crewMember.Job?.Trim() ?? string.Empty, + Type = entry.PersonType, + ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath) + }; + + if (crewMember.Id > 0) + { + personInfo.SetProviderId(MetadataProvider.Tmdb, crewMember.Id.ToString(CultureInfo.InvariantCulture)); + } + + metadataResult.AddPerson(personInfo); } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index b0a1e00df..cfef0d656 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -42,6 +42,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<Season>(); + var config = Plugin.Instance.Configuration; info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? seriesTmdbId); @@ -65,10 +66,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV result.Item = new Season { IndexNumber = seasonNumber, - Overview = seasonResult.Overview + Overview = seasonResult.Overview, + PremiereDate = seasonResult.AirDate, + ProductionYear = seasonResult.AirDate?.Year }; - if (Plugin.Instance.Configuration.ImportSeasonName) + if (config.ImportSeasonName) { result.Item.Name = seasonResult.Name; } @@ -77,47 +80,81 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // TODO why was this disabled? var credits = seasonResult.Credits; + if (credits?.Cast is not null) { - var cast = credits.Cast.OrderBy(c => c.Order).Take(Plugin.Instance.Configuration.MaxCastMembers).ToList(); - for (var i = 0; i < cast.Count; i++) + var castQuery = config.HideMissingCastMembers + ? credits.Cast.Where(a => !string.IsNullOrEmpty(a.ProfilePath)).OrderBy(a => a.Order) + : credits.Cast.OrderBy(a => a.Order); + + foreach (var actor in castQuery.Take(config.MaxCastMembers)) { - var member = cast[i]; - result.AddPerson(new PersonInfo + if (string.IsNullOrWhiteSpace(actor.Name)) + { + continue; + } + + var personInfo = new PersonInfo { - Name = member.Name.Trim(), - Role = member.Character.Trim(), + Name = actor.Name.Trim(), + Role = actor.Character?.Trim() ?? string.Empty, Type = PersonKind.Actor, - SortOrder = member.Order - }); + SortOrder = actor.Order, + ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath) + }; + + if (actor.Id > 0) + { + personInfo.SetProviderId(MetadataProvider.Tmdb, actor.Id.ToString(CultureInfo.InvariantCulture)); + } + + result.AddPerson(personInfo); } } if (credits?.Crew is not null) { - foreach (var person in credits.Crew) + var crewQuery = credits.Crew + .Select(crewMember => new + { + CrewMember = crewMember, + PersonType = TmdbUtils.MapCrewToPersonType(crewMember) + }) + .Where(entry => + TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || + TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + + if (config.HideMissingCrewMembers) { - // Normalize this - var type = TmdbUtils.MapCrewToPersonType(person); + crewQuery = crewQuery.Where(entry => !string.IsNullOrEmpty(entry.CrewMember.ProfilePath)); + } - if (!TmdbUtils.WantedCrewKinds.Contains(type) - && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + foreach (var entry in crewQuery.Take(config.MaxCrewMembers)) + { + var crewMember = entry.CrewMember; + + if (string.IsNullOrWhiteSpace(crewMember.Name)) { continue; } - result.AddPerson(new PersonInfo + var personInfo = new PersonInfo { - Name = person.Name.Trim(), - Role = person.Job?.Trim(), - Type = type - }); + Name = crewMember.Name.Trim(), + Role = crewMember.Job?.Trim() ?? string.Empty, + Type = entry.PersonType, + ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath) + }; + + if (crewMember.Id > 0) + { + personInfo.SetProviderId(MetadataProvider.Tmdb, crewMember.Id.ToString(CultureInfo.InvariantCulture)); + } + + result.AddPerson(personInfo); } } - result.Item.PremiereDate = seasonResult.AirDate; - result.Item.ProductionYear = seasonResult.AirDate?.Year; - return result; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 9ace9c674..8791712c7 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -323,17 +323,31 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV private IEnumerable<PersonInfo> GetPersons(TvShow seriesResult) { + var config = Plugin.Instance.Configuration; + if (seriesResult.Credits?.Cast is not null) { - foreach (var actor in seriesResult.Credits.Cast.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers)) + IEnumerable<Cast> castQuery = seriesResult.Credits.Cast.OrderBy(a => a.Order); + + if (config.HideMissingCastMembers) + { + castQuery = castQuery.Where(a => !string.IsNullOrEmpty(a.ProfilePath)); + } + + foreach (var actor in castQuery.Take(config.MaxCastMembers)) { + if (string.IsNullOrWhiteSpace(actor.Name)) + { + continue; + } + var personInfo = new PersonInfo { Name = actor.Name.Trim(), - Role = actor.Character.Trim(), + Role = actor.Character?.Trim() ?? string.Empty, Type = PersonKind.Actor, SortOrder = actor.Order, - ImageUrl = _tmdbClientManager.GetPosterUrl(actor.ProfilePath) + ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath) }; if (actor.Id > 0) @@ -347,30 +361,44 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (seriesResult.Credits?.Crew is not null) { - var keepTypes = new[] + var crewQuery = seriesResult.Credits.Crew + .Select(crewMember => new + { + CrewMember = crewMember, + PersonType = TmdbUtils.MapCrewToPersonType(crewMember) + }) + .Where(entry => + TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || + TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + + if (config.HideMissingCrewMembers) { - PersonType.Director, - PersonType.Writer, - PersonType.Producer - }; + crewQuery = crewQuery.Where(entry => !string.IsNullOrEmpty(entry.CrewMember.ProfilePath)); + } - foreach (var person in seriesResult.Credits.Crew) + foreach (var entry in crewQuery.Take(config.MaxCrewMembers)) { - // Normalize this - var type = TmdbUtils.MapCrewToPersonType(person); + var crewMember = entry.CrewMember; - if (!TmdbUtils.WantedCrewKinds.Contains(type) - && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrWhiteSpace(crewMember.Name)) { continue; } - yield return new PersonInfo + var personInfo = new PersonInfo { - Name = person.Name.Trim(), - Role = person.Job?.Trim(), - Type = type + Name = crewMember.Name.Trim(), + Role = crewMember.Job?.Trim() ?? string.Empty, + Type = entry.PersonType, + ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath) }; + + if (crewMember.Id > 0) + { + personInfo.SetProviderId(MetadataProvider.Tmdb, crewMember.Id.ToString(CultureInfo.InvariantCulture)); + } + + yield return personInfo; } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 4916a95d9..767004c9e 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -374,7 +374,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <returns>The TMDb tv show information.</returns> public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, int year = 0, CancellationToken cancellationToken = default) { - var key = $"searchseries-{name}-{language}"; + var key = $"searchseries-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchTv>? series) && series is not null) { return series.Results; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index a7c93ac4c..afbada3b3 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -48,7 +48,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb PersonKind.Producer }; - [GeneratedRegex(@"[\W_]+")] + [GeneratedRegex(@"[\W_-[·]]+")] private static partial Regex NonWordRegex(); /// <summary> diff --git a/MediaBrowser.Providers/Studios/StudioMetadataService.cs b/MediaBrowser.Providers/Studios/StudioMetadataService.cs index d0000442f..fb8cd36c4 100644 --- a/MediaBrowser.Providers/Studios/StudioMetadataService.cs +++ b/MediaBrowser.Providers/Studios/StudioMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class StudioMetadataService : MetadataService<Studio, ItemLookupInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public StudioMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<StudioMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } } diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs index b2b6cd9ab..31f068711 100644 --- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs +++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs @@ -3,6 +3,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -25,14 +26,16 @@ public class EpisodeMetadataService : MetadataService<Episode, EpisodeInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public EpisodeMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<EpisodeMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/TV/SeasonMetadataService.cs b/MediaBrowser.Providers/TV/SeasonMetadataService.cs index ea228a658..886175dea 100644 --- a/MediaBrowser.Providers/TV/SeasonMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeasonMetadataService.cs @@ -6,6 +6,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -28,14 +29,16 @@ public class SeasonMetadataService : MetadataService<Season, SeasonInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public SeasonMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<SeasonMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index 0ccb7f80e..c3a6ddd6a 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -10,6 +10,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -36,6 +37,7 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public SeriesMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<SeriesMetadataService> logger, @@ -43,8 +45,9 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> IFileSystem fileSystem, ILibraryManager libraryManager, ILocalizationManager localizationManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { _localizationManager = localizationManager; } diff --git a/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs b/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs index 768e4617b..926a962e2 100644 --- a/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs +++ b/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs @@ -56,7 +56,7 @@ public class TrickplayProvider : ICustomMetadataProvider<Episode>, if (item.IsFileProtocol) { var file = directoryService.GetFile(item.Path); - if (file is not null && item.DateModified != file.LastWriteTimeUtc) + if (file is not null && item.HasChanged(file.LastWriteTimeUtc)) { return true; } diff --git a/MediaBrowser.Providers/Videos/VideoMetadataService.cs b/MediaBrowser.Providers/Videos/VideoMetadataService.cs index c739bac7d..464b337ff 100644 --- a/MediaBrowser.Providers/Videos/VideoMetadataService.cs +++ b/MediaBrowser.Providers/Videos/VideoMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class VideoMetadataService : MetadataService<Video, ItemLookupInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public VideoMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<VideoMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } diff --git a/MediaBrowser.Providers/Years/YearMetadataService.cs b/MediaBrowser.Providers/Years/YearMetadataService.cs index da5f56277..cc403e7c9 100644 --- a/MediaBrowser.Providers/Years/YearMetadataService.cs +++ b/MediaBrowser.Providers/Years/YearMetadataService.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; @@ -23,14 +24,16 @@ public class YearMetadataService : MetadataService<Year, ItemLookupInfo> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param> + /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> public YearMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<YearMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IExternalDataManager externalDataManager) - : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager) + IExternalDataManager externalDataManager, + IItemRepository itemRepository) + : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository) { } } diff --git a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs index 22c065b5d..c671e7a93 100644 --- a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs @@ -1,5 +1,6 @@ #pragma warning disable CS1591 +using System; using System.IO; using System.Threading; using System.Threading.Tasks; diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs index 71d60fc25..cd14764e4 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs @@ -22,7 +22,7 @@ public class BaseItemImageInfo /// <summary> /// Gets or Sets the time the image was last modified. /// </summary> - public DateTime DateModified { get; set; } + public DateTime? DateModified { get; set; } /// <summary> /// Gets or Sets the imagetype. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs index cd8068661..3d8b01c2b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs @@ -69,6 +69,11 @@ public class UserData public bool? Likes { get; set; } /// <summary> + /// Gets or Sets the date the referenced <see cref="Item"/> has been deleted. + /// </summary> + public DateTime? RetentionDate { get; set; } + + /// <summary> /// Gets or sets the key. /// </summary> /// <value>The key.</value> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs index 6b35810b2..27dbeaba6 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Database.Implementations.DbConfiguration; using Microsoft.EntityFrameworkCore; namespace Jellyfin.Database.Implementations; @@ -20,7 +21,8 @@ public interface IJellyfinDatabaseProvider /// Initialises jellyfins EFCore database access. /// </summary> /// <param name="options">The EFCore database options.</param> - void Initialise(DbContextOptionsBuilder options); + /// <param name="databaseConfiguration">The Jellyfin database options.</param> + void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration); /// <summary> /// Will be invoked when EFCore wants to build its model. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs index 4a76113bf..bcf458abd 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs @@ -1,3 +1,4 @@ +using System; using Jellyfin.Database.Implementations.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -53,5 +54,12 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity> builder.HasIndex(e => new { e.IsFolder, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey, e.DateCreated }); // resume builder.HasIndex(e => new { e.MediaType, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey }); + + builder.HasData(new BaseItemEntity() + { + Id = Guid.Parse("00000000-0000-0000-0000-000000000001"), + Type = "PLACEHOLDER", + Name = "This is a placeholder item for UserData that has been detacted from its original item", + }); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs index 47604d321..e7b436293 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs @@ -17,6 +17,6 @@ public class UserDataConfiguration : IEntityTypeConfiguration<UserData> builder.HasIndex(d => new { d.ItemId, d.UserId, d.PlaybackPositionTicks }); builder.HasIndex(d => new { d.ItemId, d.UserId, d.IsFavorite }); builder.HasIndex(d => new { d.ItemId, d.UserId, d.LastPlayedDate }); - builder.HasOne(e => e.Item); + builder.HasOne(e => e.Item).WithMany(e => e.UserData); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs index bb66bddca..c20dfeeb5 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs @@ -82,7 +82,7 @@ public static class QueryPartitionHelpers /// <typeparam name="TEntity">The entity to load.</typeparam> /// <param name="partitionInfo">The source query.</param> /// <param name="partitionSize">The number of elements to load per partition.</param> - /// <param name="cancellationToken">The cancelation token.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>A enumerable representing the whole of the query.</returns> public static async IAsyncEnumerable<TEntity> PartitionAsync<TEntity>(this ProgressablePartitionReporting<TEntity> partitionInfo, int partitionSize, [EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -98,7 +98,7 @@ public static class QueryPartitionHelpers /// <typeparam name="TEntity">The entity to load.</typeparam> /// <param name="partitionInfo">The source query.</param> /// <param name="partitionSize">The number of elements to load per partition.</param> - /// <param name="cancellationToken">The cancelation token.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>A enumerable representing the whole of the query.</returns> public static async IAsyncEnumerable<TEntity> PartitionEagerAsync<TEntity>(this ProgressablePartitionReporting<TEntity> partitionInfo, int partitionSize, [EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -115,7 +115,7 @@ public static class QueryPartitionHelpers /// <param name="query">The source query.</param> /// <param name="partitionSize">The number of elements to load per partition.</param> /// <param name="progressablePartition">Reporting helper.</param> - /// <param name="cancellationToken">The cancelation token.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>A enumerable representing the whole of the query.</returns> public static async IAsyncEnumerable<TEntity> PartitionAsync<TEntity>( this IOrderedQueryable<TEntity> query, @@ -154,7 +154,7 @@ public static class QueryPartitionHelpers /// <param name="query">The source query.</param> /// <param name="partitionSize">The number of elements to load per partition.</param> /// <param name="progressablePartition">Reporting helper.</param> - /// <param name="cancellationToken">The cancelation token.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>A enumerable representing the whole of the query.</returns> public static async IAsyncEnumerable<TEntity> PartitionEagerAsync<TEntity>( this IOrderedQueryable<TEntity> query, diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.Designer.cs new file mode 100644 index 000000000..253e67e20 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.Designer.cs @@ -0,0 +1,1693 @@ +// <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("20250609115616_DetachUserDataInsteadOfDelete")] + partial class DetachUserDataInsteadOfDelete + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.5"); + + 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); + }); + + 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<DateTimeOffset?>("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.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.SetNull); + + 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("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/20250609115616_DetachUserDataInsteadOfDelete.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.cs new file mode 100644 index 000000000..2935a608d --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class DetachUserDataInsteadOfDelete : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<DateTimeOffset>( + name: "RetentionDate", + table: "UserData", + type: "TEXT", + nullable: true); + + migrationBuilder.InsertData( + table: "BaseItems", + columns: new[] { "Id", "Album", "AlbumArtists", "Artists", "Audio", "ChannelId", "CleanName", "CommunityRating", "CriticRating", "CustomRating", "Data", "DateCreated", "DateLastMediaAdded", "DateLastRefreshed", "DateLastSaved", "DateModified", "EndDate", "EpisodeTitle", "ExternalId", "ExternalSeriesId", "ExternalServiceId", "ExtraIds", "ExtraType", "ForcedSortName", "Genres", "Height", "IndexNumber", "InheritedParentalRatingSubValue", "InheritedParentalRatingValue", "IsFolder", "IsInMixedFolder", "IsLocked", "IsMovie", "IsRepeat", "IsSeries", "IsVirtualItem", "LUFS", "MediaType", "Name", "NormalizationGain", "OfficialRating", "OriginalTitle", "Overview", "OwnerId", "ParentId", "ParentIndexNumber", "Path", "PreferredMetadataCountryCode", "PreferredMetadataLanguage", "PremiereDate", "PresentationUniqueKey", "PrimaryVersionId", "ProductionLocations", "ProductionYear", "RunTimeTicks", "SeasonId", "SeasonName", "SeriesId", "SeriesName", "SeriesPresentationUniqueKey", "ShowId", "Size", "SortName", "StartDate", "Studios", "Tagline", "Tags", "TopParentId", "TotalBitrate", "Type", "UnratedType", "Width" }, + values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false, false, false, false, false, false, false, null, null, "This is a placeholder item for UserData that has been detacted from its original item", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, "PLACEHOLDER", null, null }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RetentionDate", + table: "UserData"); + + migrationBuilder.DeleteData( + table: "BaseItems", + keyColumn: "Id", + keyValue: new Guid("00000000-0000-0000-0000-000000000001")); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.Designer.cs new file mode 100644 index 000000000..a0622c14d --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.Designer.cs @@ -0,0 +1,1709 @@ +// <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("20250622170802_BaseItemImageInfoDateModifiedNullable")] + partial class BaseItemImageInfoDateModifiedNullable + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + 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.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("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/20250622170802_BaseItemImageInfoDateModifiedNullable.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.cs new file mode 100644 index 000000000..bce6029d5 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class BaseItemImageInfoDateModifiedNullable : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn<DateTime>( + name: "DateModified", + table: "BaseItemImageInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "TEXT"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn<DateTime>( + name: "DateModified", + table: "BaseItemImageInfos", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + oldClrType: typeof(DateTime), + oldType: "TEXT", + oldNullable: true); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.Designer.cs new file mode 100644 index 000000000..3ceb907c1 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.Designer.cs @@ -0,0 +1,1709 @@ +// <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("20250714044826_ResetJournalMode")] + partial class ResetJournalMode + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + + 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.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("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/20250714044826_ResetJournalMode.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.cs new file mode 100644 index 000000000..23cb0c8ba --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class ResetJournalMode : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + // Resets journal mode to WAL for users that have created their database during 10.11-RC1 or 2 + migrationBuilder.Sql("PRAGMA journal_mode = 'WAL';", true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} 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 dcdc5dd3e..a7ff802af 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.3"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => { @@ -392,6 +392,21 @@ namespace Jellyfin.Server.Implementations.Migrations 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 => @@ -403,7 +418,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<byte[]>("Blurhash") .HasColumnType("BLOB"); - b.Property<DateTime>("DateModified") + b.Property<DateTime?>("DateModified") .HasColumnType("TEXT"); b.Property<int>("Height") @@ -1373,6 +1388,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<double?>("Rating") .HasColumnType("REAL"); + b.Property<DateTime?>("RetentionDate") + .HasColumnType("TEXT"); + b.Property<int?>("SubtitleStreamIndex") .HasColumnType("INTEGER"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index dda1ca075..e52ab69d7 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -2,9 +2,11 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.DbConfiguration; using MediaBrowser.Common.Configuration; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -38,11 +40,16 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; } /// <inheritdoc/> - public void Initialise(DbContextOptionsBuilder options) + public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration) { + 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); + options .UseSqlite( - $"Filename={Path.Combine(_applicationPaths.DataPath, "jellyfin.db")};Pooling=false", + sqliteConnectionBuilder.ToString(), sqLiteOptions => sqLiteOptions.MigrationsAssembly(GetType().Assembly)) // TODO: Remove when https://github.com/dotnet/efcore/pull/35873 is merged & released .ConfigureWarnings(warnings => diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 4626bc914..503e2f941 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -202,18 +202,47 @@ public class SkiaEncoder : IImageEncoder } } - using var codec = SKCodec.Create(path, out SKCodecResult result); + var safePath = NormalizePath(path); + if (new FileInfo(safePath).Length == 0) + { + _logger.LogDebug("Skip zero‑byte image {FilePath}", path); + return default; + } + + using var codec = SKCodec.Create(safePath, out var result); + switch (result) { case SKCodecResult.Success: + // Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel + // decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.) + // `SKCodec.Create` returns a *non‑null* codec together with + // SKCodecResult.InternalError. The header still contains valid dimensions, + // which is all we need here – so we fall back to them instead of aborting. + // See e.g. Skia bugs #4139, #6092. + case SKCodecResult.InternalError when codec is not null: var info = codec.Info; return new ImageDimensions(info.Width, info.Height); + case SKCodecResult.Unimplemented: _logger.LogDebug("Image format not supported: {FilePath}", path); return default; + default: - _logger.LogError("Unable to determine image dimensions for {FilePath}: {SkCodecResult}", path, result); + { + var boundsInfo = SKBitmap.DecodeBounds(safePath); + + if (boundsInfo.Width > 0 && boundsInfo.Height > 0) + { + return new ImageDimensions(boundsInfo.Width, boundsInfo.Height); + } + + _logger.LogWarning( + "Unable to determine image dimensions for {FilePath}: {SkCodecResult}", + path, + result); return default; + } } } diff --git a/src/Jellyfin.Drawing.Skia/SkiaHelper.cs b/src/Jellyfin.Drawing.Skia/SkiaHelper.cs index bd1b2b0da..87446236c 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaHelper.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaHelper.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using SkiaSharp; namespace Jellyfin.Drawing.Skia; @@ -27,12 +28,17 @@ public static class SkiaHelper currentIndex = 0; } - SKBitmap? bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _); - + var imagePath = paths[currentIndex]; imagesTested[currentIndex] = 0; - currentIndex++; + if (!Path.Exists(imagePath)) + { + continue; + } + + SKBitmap? bitmap = skiaEncoder.Decode(imagePath, false, null, out _); + if (bitmap is not null) { newIndex = currentIndex; diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index 74182171f..8ee129a57 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -1166,7 +1166,7 @@ namespace Jellyfin.LiveTv.Channels } } - if (isNew || forceUpdate || item.DateLastRefreshed == default) + if (isNew || forceUpdate || item.DateLastRefreshed == DateTime.MinValue) { _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.Normal); } diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs index c04954207..be7ff5297 100644 --- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -73,6 +73,10 @@ namespace Jellyfin.LiveTv.IO { _targetPath = targetFile; Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); + if (!File.Exists(targetFile)) + { + FileHelper.CreateEmpty(targetFile); + } var processStartInfo = new ProcessStartInfo { diff --git a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs index d63ee6777..fcf37f35d 100644 --- a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs +++ b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs @@ -75,6 +75,8 @@ public class KeyframeExtractionScheduledTask : IScheduledTask var videos = _libraryManager.GetItemList(query); foreach (var video in videos) { + cancellationToken.ThrowIfCancellationRequested(); + // Only local files supported var path = video.Path; if (File.Exists(path)) diff --git a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs index 51eb99f49..6d887c577 100644 --- a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs @@ -23,7 +23,7 @@ namespace Jellyfin.Naming.Tests.Video Test("300-trailer.mp4", ExtraType.Trailer); Test("300.trailer.mp4", ExtraType.Trailer); Test("300_trailer.mp4", ExtraType.Trailer); - Test("300 trailer.mp4", ExtraType.Trailer); + Test("300 - trailer.mp4", ExtraType.Trailer); Test("theme.mp3", ExtraType.ThemeSong); } @@ -132,7 +132,14 @@ namespace Jellyfin.Naming.Tests.Video Test("300-sample.mp4", ExtraType.Sample); Test("300.sample.mp4", ExtraType.Sample); Test("300_sample.mp4", ExtraType.Sample); - Test("300 sample.mp4", ExtraType.Sample); + Test("300 - sample.mp4", ExtraType.Sample); + } + + [Fact] + public void TestSuffixPartOfTitle() + { + Test("I Live In A Trailer.mp4", null); + Test("The DNA Sample.mp4", null); } private void Test(string input, ExtraType? expectedType) diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs index 377f82eac..d3164ba9c 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs @@ -87,7 +87,7 @@ namespace Jellyfin.Naming.Tests.Video var files = new[] { "300.mkv", - "300 trailer.mkv" + "300 - trailer.mkv" }; var result = VideoListResolver.Resolve( diff --git a/tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs b/tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs index 756a688ab..a1fc067cc 100644 --- a/tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs +++ b/tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs @@ -20,22 +20,28 @@ public static class LrcLyricParserTests var line1 = parsed.Lyrics[0]; Assert.Equal("Every night that goes between", line1.Text); Assert.NotNull(line1.Cues); - Assert.Equal(9, line1.Cues.Count); + Assert.Equal(5, line1.Cues.Count); Assert.Equal(68400000, line1.Cues[0].Start); Assert.Equal(72000000, line1.Cues[0].End); + Assert.Equal(0, line1.Cues[0].Position); + Assert.Equal(5, line1.Cues[0].EndPosition); + Assert.Equal(6, line1.Cues[1].Position); + Assert.Equal(11, line1.Cues[1].EndPosition); + Assert.Equal(12, line1.Cues[2].Position); var line5 = parsed.Lyrics[4]; Assert.Equal("Every night you do not come", line5.Text); Assert.NotNull(line5.Cues); - Assert.Equal(11, line5.Cues.Count); - Assert.Equal(377300000, line5.Cues[5].Start); - Assert.Equal(380000000, line5.Cues[5].End); + Assert.Equal(6, line5.Cues.Count); + Assert.Equal(375200000, line5.Cues[2].Start); + Assert.Equal(377300000, line5.Cues[2].End); var lastLine = parsed.Lyrics[^1]; Assert.Equal("I have always been a storm", lastLine.Text); Assert.NotNull(lastLine.Cues); - Assert.Equal(11, lastLine.Cues.Count); + Assert.Equal(6, lastLine.Cues.Count); Assert.Equal(2358000000, lastLine.Cues[^1].Start); + Assert.Equal(26, lastLine.Cues[^1].EndPosition); Assert.Null(lastLine.Cues[^1].End); } } diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs index b32ecf6ec..b5585f4fd 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs @@ -22,7 +22,7 @@ namespace Jellyfin.Providers.Tests.Manager { var newLocked = new[] { MetadataField.Genres, MetadataField.Cast }; var newString = "new"; - var newDate = DateTime.Now; + var newDate = DateTime.UtcNow; var oldLocked = new[] { MetadataField.Genres }; var oldString = "old"; @@ -39,6 +39,7 @@ namespace Jellyfin.Providers.Tests.Manager DateCreated = newDate } }; + if (defaultDate) { source.Item.DateCreated = default; @@ -141,8 +142,8 @@ namespace Jellyfin.Providers.Tests.Manager { "ProductionYear", 1, 2 }, { "CommunityRating", 1.0f, 2.0f }, { "CriticRating", 1.0f, 2.0f }, - { "EndDate", DateTime.UnixEpoch, DateTime.Now }, - { "PremiereDate", DateTime.UnixEpoch, DateTime.Now }, + { "EndDate", DateTime.UnixEpoch, DateTime.UtcNow }, + { "PremiereDate", DateTime.UnixEpoch, DateTime.UtcNow }, { "Video3DFormat", Video3DFormat.HalfSideBySide, Video3DFormat.FullSideBySide } }; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/CoreResolutionIgnoreRuleTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/CoreResolutionIgnoreRuleTest.cs new file mode 100644 index 000000000..0495c209d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/CoreResolutionIgnoreRuleTest.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using Emby.Naming.Common; +using Emby.Naming.Video; +using Emby.Server.Implementations.Library; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public class CoreResolutionIgnoreRuleTest +{ + private readonly CoreResolutionIgnoreRule _rule; + private readonly NamingOptions _namingOptions; + private readonly Mock<IServerApplicationPaths> _appPathsMock; + + public CoreResolutionIgnoreRuleTest() + { + _namingOptions = new NamingOptions(); + + _namingOptions.AllExtrasTypesFolderNames.TryAdd("extras", ExtraType.Trailer); + + _appPathsMock = new Mock<IServerApplicationPaths>(); + _appPathsMock.SetupGet(x => x.RootFolderPath).Returns("/server/root"); + + _rule = new CoreResolutionIgnoreRule(_namingOptions, _appPathsMock.Object); + } + + private FileSystemMetadata MakeFileSystemMetadata(string fullName, bool isDirectory = false) + => new FileSystemMetadata { FullName = fullName, Name = Path.GetFileName(fullName), IsDirectory = isDirectory }; + + private BaseItem MakeParent(string name = "Parent", bool isTopParent = false, Type? type = null) + { + return type switch + { + Type t when t == typeof(Folder) => CreateMock<Folder>(name, isTopParent).Object, + Type t when t == typeof(AggregateFolder) => CreateMock<AggregateFolder>(name, isTopParent).Object, + Type t when t == typeof(UserRootFolder) => CreateMock<UserRootFolder>(name, isTopParent).Object, + _ => CreateMock<BaseItem>(name, isTopParent).Object + }; + } + + private static Mock<T> CreateMock<T>(string name, bool isTopParent) + where T : BaseItem + { + var mock = new Mock<T>(); + mock.SetupGet(p => p.Name).Returns(name); + mock.SetupGet(p => p.IsTopParent).Returns(isTopParent); + return mock; + } + + [Fact] + public void TestApplicationFolder() + { + Assert.False(_rule.ShouldIgnore( + MakeFileSystemMetadata("/server/root/extras", isDirectory: true), + null)); + + Assert.False(_rule.ShouldIgnore( + MakeFileSystemMetadata("/server/root/small.jpg"), + null)); + } + + [Fact] + public void TestTopLevelDirectory() + { + Assert.False(_rule.ShouldIgnore( + MakeFileSystemMetadata("Series/Extras", true), + MakeParent(type: typeof(AggregateFolder)))); + + Assert.False(_rule.ShouldIgnore( + MakeFileSystemMetadata("Series/Extras/Extras", true), + MakeParent(isTopParent: true))); + } + + [Fact] + public void TestIgnorePatterns() + { + Assert.False(_rule.ShouldIgnore( + MakeFileSystemMetadata("/Media/big.jpg"), + MakeParent())); + + Assert.True(_rule.ShouldIgnore( + MakeFileSystemMetadata("/Media/small.jpg"), + MakeParent())); + } + + [Fact] + public void TestExtrasTypesFolderNames() + { + FileSystemMetadata fileSystemMetadata = MakeFileSystemMetadata("/Movies/Up/extras", true); + + Assert.False(_rule.ShouldIgnore( + fileSystemMetadata, + MakeParent(type: typeof(AggregateFolder)))); + + Assert.False(_rule.ShouldIgnore( + fileSystemMetadata, + MakeParent(type: typeof(UserRootFolder)))); + + Assert.False(_rule.ShouldIgnore( + fileSystemMetadata, + null)); + + Assert.True(_rule.ShouldIgnore( + fileSystemMetadata, + MakeParent())); + + Assert.True(_rule.ShouldIgnore( + fileSystemMetadata, + MakeParent(type: typeof(Folder)))); + } + + [Fact] + public void TestThemeSong() + { + Assert.False(_rule.ShouldIgnore( + MakeFileSystemMetadata("/Movies/Up/intro.mp3"), + MakeParent())); + + Assert.True(_rule.ShouldIgnore( + MakeFileSystemMetadata("/Movies/Up/theme.mp3"), + MakeParent())); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index a7a1e5e81..6d6bba4fc 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -41,7 +41,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization await localizationManager.LoadAll(); var cultures = localizationManager.GetCultures().ToList(); - Assert.Equal(191, cultures.Count); + Assert.Equal(194, cultures.Count); var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal)); Assert.NotNull(germany); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index b289c763b..3d8ea15a3 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -185,7 +185,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins Description = packageInfo.Description, Overview = packageInfo.Overview, TargetAbi = packageInfo.Versions[0].TargetAbi!, - Timestamp = DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture), + Timestamp = DateTimeOffset.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture).UtcDateTime, Changelog = packageInfo.Versions[0].Changelog!, Version = new Version(1, 0).ToString(), ImagePath = string.Empty @@ -221,7 +221,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins Description = packageInfo.Description, Overview = packageInfo.Overview, TargetAbi = packageInfo.Versions[0].TargetAbi!, - Timestamp = DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture), + Timestamp = DateTimeOffset.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture).UtcDateTime, Changelog = packageInfo.Versions[0].Changelog!, Version = packageInfo.Versions[0].Version, ImagePath = string.Empty @@ -301,7 +301,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins var versionInfo = fixture.Create<VersionInfo>(); versionInfo.Version = new Version(1, 0).ToString(); - versionInfo.Timestamp = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture); + versionInfo.Timestamp = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); var packageInfo = fixture.Create<PackageInfo>(); packageInfo.Versions = new[] { versionInfo }; diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index 725e359d7..0952fb8b6 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -98,7 +98,10 @@ namespace Jellyfin.Server.Integration.Tests .AddEnvironmentVariables("JELLYFIN_") .AddInMemoryCollection(commandLineOpts.ConvertToConfig()); }) - .ConfigureServices(e => e.AddSingleton<IStartupLogger, NullStartupLogger>().AddSingleton(e)); + .ConfigureServices(e => e + .AddSingleton<IStartupLogger, NullStartupLogger<object>>() + .AddTransient(typeof(IStartupLogger<>), typeof(NullStartupLogger<>)) + .AddSingleton(e)); } /// <inheritdoc/> @@ -132,13 +135,20 @@ namespace Jellyfin.Server.Integration.Tests base.Dispose(disposing); } - private sealed class NullStartupLogger : IStartupLogger + private sealed class NullStartupLogger<TCategory> : IStartupLogger<TCategory> { + public StartupLogTopic? Topic => throw new NotImplementedException(); + public IStartupLogger BeginGroup(FormattableString logEntry) { return this; } + public IStartupLogger<TCategory1> BeginGroup<TCategory1>(FormattableString logEntry) + { + return new NullStartupLogger<TCategory1>(); + } + public IDisposable? BeginScope<TState>(TState state) where TState : notnull { @@ -160,10 +170,25 @@ namespace Jellyfin.Server.Integration.Tests return this; } + public IStartupLogger<TCategory1> With<TCategory1>(Microsoft.Extensions.Logging.ILogger logger) + { + return new NullStartupLogger<TCategory1>(); + } + + IStartupLogger<TCategory> IStartupLogger<TCategory>.BeginGroup(FormattableString logEntry) + { + return new NullStartupLogger<TCategory>(); + } + IStartupLogger IStartupLogger.With(Microsoft.Extensions.Logging.ILogger logger) { return this; } + + IStartupLogger<TCategory> IStartupLogger<TCategory>.With(Microsoft.Extensions.Logging.ILogger logger) + { + return this; + } } } } |
