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