Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df035a069d | |||
| 4e6bf45701 | |||
| 2415f288f7 | |||
| 94e46a04a7 | |||
| 0535515b11 | |||
| 401c24f599 | |||
| 41e737f3ec | |||
| aca106bfe9 | |||
| 72081ca13d | |||
| 9c9ad0f88e | |||
| 90fb90d0be | |||
| 7a9981b872 | |||
| 2ba6c44ae8 | |||
| 31d8aa1fc4 | |||
| d6cd89ecd0 | |||
| dce22a558f | |||
| 1abf61a05f | |||
| 4f651fc239 | |||
| 421a8cc092 | |||
| 9bcb8501ab | |||
| bf51bff748 | |||
| 8d3f014b29 | |||
| e7c8d202cc | |||
| c5c5937978 | |||
| 96150a54e3 | |||
| aa0ad68251 | |||
| 81f8a702de | |||
| b475a8264a | |||
| 4a408dc625 | |||
| b2d7513cf2 | |||
| 315b7e0bd0 | |||
| 4ccd84342b | |||
| e67c191843 | |||
| 3e5d29225a | |||
| e1f7a4bee9 | |||
| 9fc10226c9 | |||
| 372d97885f | |||
| 57d6342524 | |||
| 6d5282208b | |||
| f246d2ad39 | |||
| 189c7edcf6 | |||
| 4ba580a761 | |||
| e81c127514 | |||
| dff8539e8b | |||
| bad167656a | |||
| 786c61ba09 | |||
| aead802df6 | |||
| dd7c38fb5d | |||
| bb309a5120 | |||
| 96c85f95c5 | |||
| 254569237d | |||
| c5d1033680 | |||
| 41368c2dbc | |||
| 35923c59e0 | |||
| 35a3634c34 | |||
| 2d68b4fda0 | |||
| 8e62b84a31 | |||
| f8f407c8bd | |||
| 613d499fab | |||
| 769f2a804a | |||
| ee2aa8c13c | |||
| 945226050f | |||
| 41e8477673 | |||
| eccb1359c8 | |||
| 45dc885cd5 | |||
| 73d3c0f733 | |||
| 2183ed4d63 | |||
| e6e7c49888 | |||
| 7deb7c1432 | |||
| 077f10a4af | |||
| a872ebc640 | |||
| 43e866f446 | |||
| e790f84ace | |||
| caa30d8b88 | |||
| bb9329736f | |||
| d4426fe0f6 | |||
| 7657caa960 | |||
| ac1c2c6c82 | |||
| 2209f0e1c0 | |||
| 7e2c08499b | |||
| 75acefe989 | |||
| c4127e7749 | |||
| 2d6e10e163 | |||
| 5c711bc235 | |||
| 091114657b | |||
| 5202d72999 | |||
| f47a544c7a | |||
| a3d76901ed | |||
| f2ddde1747 | |||
| 14964da5e8 | |||
| e7aa3024cb | |||
| 2ba3335ca0 | |||
| 3c5f94c242 | |||
| a1c9488c6c | |||
| 26791b568b | |||
| 7c032fe4e2 | |||
| ff89f36120 | |||
| a695189734 | |||
| aa57e311ab | |||
| 3b161b4731 | |||
| 8b0503f398 | |||
| a5f5bbe4a6 | |||
| 89d4a3d18a | |||
| 319c309be9 | |||
| acc51e6923 | |||
| 91f4bf1590 | |||
| fb18041518 | |||
| ebcab4e73c | |||
| eac9e61f1c | |||
| 89201f2b94 | |||
| 755a572cfa | |||
| 656b61deac | |||
| 02f8159bc6 | |||
| f8873f91f3 | |||
| c2e4265b4d | |||
| b380ef5ef3 | |||
| 9d7ecaca01 | |||
| 7510d3101b |
+16
-2
@@ -7,7 +7,11 @@
|
|||||||
[Rr]elease/
|
[Rr]elease/
|
||||||
[Bb]in/
|
[Bb]in/
|
||||||
[Oo]bj/
|
[Oo]bj/
|
||||||
|
.idea/
|
||||||
|
*.lscache
|
||||||
|
.github/
|
||||||
|
|
||||||
|
.github
|
||||||
/.vs/Jellyfin
|
/.vs/Jellyfin
|
||||||
/.vs
|
/.vs
|
||||||
**/obj/
|
**/obj/
|
||||||
@@ -20,16 +24,26 @@
|
|||||||
*.pdb
|
*.pdb
|
||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
*.lscache
|
||||||
|
|
||||||
# Centralized lib output folder
|
# Centralized lib output folder
|
||||||
/lib/
|
/lib/
|
||||||
lib/
|
lib/
|
||||||
/installer-output/
|
/installer-output/
|
||||||
installer-output/
|
installer-output/
|
||||||
/wwwroot/*
|
|
||||||
wwwroot/*
|
|
||||||
# Publish profiles (anywhere in project)
|
# Publish profiles (anywhere in project)
|
||||||
/Properties/PublishProfiles/
|
/Properties/PublishProfiles/
|
||||||
Properties/PublishProfiles/
|
Properties/PublishProfiles/
|
||||||
**/PublishProfiles/
|
**/PublishProfiles/
|
||||||
**/Properties/PublishProfiles/
|
**/Properties/PublishProfiles/
|
||||||
|
|
||||||
|
# Ignore schema directories (database schema dumps)
|
||||||
|
**/schema/
|
||||||
|
schema/
|
||||||
|
[Rr]eports/
|
||||||
|
# Ignore .idea IDE directory
|
||||||
|
**/.idea/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
**/scenario.json
|
||||||
|
scenario.json
|
||||||
|
|||||||
Generated
-4
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
|
||||||
</project>
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
<NoWarn>$(NoWarn);NU1510</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
||||||
<ItemGroup Label="Package Dependencies">
|
<ItemGroup Label="Package Dependencies">
|
||||||
@@ -44,14 +45,16 @@
|
|||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="11.0.0-preview.1.26104.118" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="11.0.0-preview.1.26104.118" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="11.0.0-preview.1.26104.118" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="11.0.0-preview.1.26104.118" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="11.0.0-preview.1.26104.118" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="11.0.0-preview.1.26104.118" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="11.0.0-preview.1.26104.118" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="11.0.0-preview.1.26104.118" />
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="11.0.0-preview.1.26104.118" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="11.0.0-preview.1.26104.118" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="11.0.0-preview.1.26104.118" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="11.0.0-preview.1.26104.118" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="11.0.0-preview.1.26104.118" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="11.0.0-preview.1.26104.118" />
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="11.0.0-preview.1.26104.118" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="11.0.0-preview.1.26104.118" />
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="11.0.0-preview.1.26104.118" />
|
||||||
|
<PackageVersion Include="Microsoft.Kiota.Abstractions" Version="1.22.0" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||||
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
||||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||||
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
@@ -64,7 +67,7 @@
|
|||||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
<WarningsAsErrors></WarningsAsErrors>
|
<WarningsAsErrors></WarningsAsErrors>
|
||||||
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
||||||
<NoWarn>$(NoWarn);IDE0065</NoWarn>
|
<NoWarn>$(NoWarn);IDE0065;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CA1865;CA1869;CA1716;CA2101;SA1127;CA1845;CA1859;CA2007</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
|
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
|
||||||
@@ -32,6 +32,10 @@
|
|||||||
<Compile Include="../SharedVersion.cs" />
|
<Compile Include="../SharedVersion.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../MediaBrowser.Common/MediaBrowser.Common.csproj" />
|
<ProjectReference Include="../MediaBrowser.Common/MediaBrowser.Common.csproj" />
|
||||||
<ProjectReference Include="../MediaBrowser.Model/MediaBrowser.Model.csproj" />
|
<ProjectReference Include="../MediaBrowser.Model/MediaBrowser.Model.csproj" />
|
||||||
@@ -62,7 +66,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<NoWarn>$(NoWarn);NETSDK1057</NoWarn>
|
<NoWarn>$(NoWarn);NETSDK1057;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CA1865;CA1869;CA1716;CA2101;SA1127;CA1845;CA1859;CA2007</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ProjectGuid>{89AB4548-770D-41FD-A891-8DAFF44F452C}</ProjectGuid>
|
<ProjectGuid>{89AB4548-770D-41FD-A891-8DAFF44F452C}</ProjectGuid>
|
||||||
|
<NoWarn>$(NoWarn);CA1062;CA1031;CA1848;CA2253;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CA1865;CA1869;CA1716;CA2101;SA1127;CA1845;CA1859;CA2007</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
||||||
<PackageReference Include="TagLibSharp" />
|
<PackageReference Include="TagLibSharp" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Events;
|
using MediaBrowser.Common.Events;
|
||||||
@@ -306,6 +307,32 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
|
|
||||||
private object LoadConfiguration(string path, Type configurationType)
|
private object LoadConfiguration(string path, Type configurationType)
|
||||||
{
|
{
|
||||||
|
// Try JSON first, then fallback to XML for backward compatibility
|
||||||
|
var jsonPath = Path.ChangeExtension(path, ".json");
|
||||||
|
|
||||||
|
// Try loading from .json if it exists
|
||||||
|
if (File.Exists(jsonPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = typeof(ConfigurationHelper).GetMethod(
|
||||||
|
nameof(ConfigurationHelper.GetJsonConfiguration),
|
||||||
|
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public);
|
||||||
|
|
||||||
|
if (method is not null)
|
||||||
|
{
|
||||||
|
var genericMethod = method.MakeGenericMethod(configurationType);
|
||||||
|
return genericMethod.Invoke(null, new object[] { jsonPath })
|
||||||
|
?? throw new InvalidOperationException("JSON deserialization returned null.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error loading JSON configuration file: {Path}", jsonPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to XML for backward compatibility
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (File.Exists(path))
|
if (File.Exists(path))
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
{
|
{
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Jellyfin.Extensions.Json;
|
||||||
using MediaBrowser.Model.Serialization;
|
using MediaBrowser.Model.Serialization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -54,10 +56,54 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
// Save it after load in case we got new items
|
// Save it after load in case we got new items
|
||||||
|
#pragma warning disable IDE0063 // Use simple 'using' statement
|
||||||
using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
|
using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||||
{
|
{
|
||||||
fs.Write(newBytes);
|
fs.Write(newBytes);
|
||||||
}
|
}
|
||||||
|
#pragma warning restore IDE0063 // Use simple 'using' statement
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a JSON configuration file from the file system using System.Text.Json.
|
||||||
|
/// It will immediately re-serialize and save if new serialization data is available due to property changes.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of configuration to deserialize.</typeparam>
|
||||||
|
/// <param name="path">The path to the JSON configuration file.</param>
|
||||||
|
/// <returns>The deserialized configuration object.</returns>
|
||||||
|
public static T GetJsonConfiguration<T>(string path)
|
||||||
|
where T : class, new()
|
||||||
|
{
|
||||||
|
T configuration;
|
||||||
|
byte[]? buffer = null;
|
||||||
|
|
||||||
|
// Use try/catch to avoid the extra file system lookup using File.Exists
|
||||||
|
try
|
||||||
|
{
|
||||||
|
buffer = File.ReadAllBytes(path);
|
||||||
|
configuration = JsonSerializer.Deserialize<T>(buffer, JsonDefaults.Options) ?? new T();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// If file doesn't exist or deserialization fails, create a new instance with defaults
|
||||||
|
configuration = new T();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-serialize to ensure proper formatting and any new defaults are captured
|
||||||
|
byte[] newBytes = JsonSerializer.SerializeToUtf8Bytes(configuration, JsonDefaults.Options);
|
||||||
|
|
||||||
|
// If the file didn't exist before, or if something has changed, re-save
|
||||||
|
if (buffer is null || !newBytes.AsSpan().SequenceEqual(buffer))
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path));
|
||||||
|
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
// Save it after load in case we got new items or formatting changed
|
||||||
|
File.WriteAllBytes(path, newBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
return configuration;
|
return configuration;
|
||||||
|
|||||||
@@ -517,6 +517,7 @@ namespace Emby.Server.Implementations
|
|||||||
serviceCollection.AddSingleton<IMediaAttachmentRepository, MediaAttachmentRepository>();
|
serviceCollection.AddSingleton<IMediaAttachmentRepository, MediaAttachmentRepository>();
|
||||||
serviceCollection.AddSingleton<IMediaStreamRepository, MediaStreamRepository>();
|
serviceCollection.AddSingleton<IMediaStreamRepository, MediaStreamRepository>();
|
||||||
serviceCollection.AddSingleton<IKeyframeRepository, KeyframeRepository>();
|
serviceCollection.AddSingleton<IKeyframeRepository, KeyframeRepository>();
|
||||||
|
serviceCollection.AddSingleton<ILibraryOptionsRepository, LibraryOptionsRepository>();
|
||||||
serviceCollection.AddSingleton<IItemTypeLookup, ItemTypeLookup>();
|
serviceCollection.AddSingleton<IItemTypeLookup, ItemTypeLookup>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>();
|
serviceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>();
|
||||||
@@ -600,11 +601,37 @@ namespace Emby.Server.Implementations
|
|||||||
|
|
||||||
FindParts();
|
FindParts();
|
||||||
|
|
||||||
|
BackfillLibraryOptionsFromXml();
|
||||||
|
|
||||||
// Ensure at least one user exists
|
// Ensure at least one user exists
|
||||||
var userManager = Resolve<IUserManager>();
|
var userManager = Resolve<IUserManager>();
|
||||||
await userManager.InitializeAsync().ConfigureAwait(false);
|
await userManager.InitializeAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void BackfillLibraryOptionsFromXml()
|
||||||
|
{
|
||||||
|
var defaultUserViewsPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||||
|
|
||||||
|
if (!Directory.Exists(defaultUserViewsPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var virtualFolderPath in Directory.GetDirectories(defaultUserViewsPath))
|
||||||
|
{
|
||||||
|
// DB-first with XML fallback is implemented in CollectionFolder.GetLibraryOptions.
|
||||||
|
// Calling this at startup backfills existing options.xml rows without waiting for first user read.
|
||||||
|
_ = CollectionFolder.GetLibraryOptions(virtualFolderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error backfilling library options from {DefaultUserViewsPath}", defaultUserViewsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private X509Certificate2 GetCertificate(string path, string password)
|
private X509Certificate2 GetCertificate(string path, string password)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
@@ -658,6 +685,7 @@ namespace Emby.Server.Implementations
|
|||||||
BaseItem.UserDataManager = Resolve<IUserDataManager>();
|
BaseItem.UserDataManager = Resolve<IUserDataManager>();
|
||||||
CollectionFolder.XmlSerializer = _xmlSerializer;
|
CollectionFolder.XmlSerializer = _xmlSerializer;
|
||||||
CollectionFolder.ApplicationHost = this;
|
CollectionFolder.ApplicationHost = this;
|
||||||
|
CollectionFolder.LibraryOptionsRepository = Resolve<ILibraryOptionsRepository>();
|
||||||
Folder.UserViewManager = Resolve<IUserViewManager>();
|
Folder.UserViewManager = Resolve<IUserViewManager>();
|
||||||
Folder.CollectionManager = Resolve<ICollectionManager>();
|
Folder.CollectionManager = Resolve<ICollectionManager>();
|
||||||
Folder.LimitedConcurrencyLibraryScheduler = Resolve<ILimitedConcurrencyLibraryScheduler>();
|
Folder.LimitedConcurrencyLibraryScheduler = Resolve<ILimitedConcurrencyLibraryScheduler>();
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ namespace Emby.Server.Implementations
|
|||||||
{ FfmpegProbeSizeKey, "1G" },
|
{ FfmpegProbeSizeKey, "1G" },
|
||||||
{ FfmpegAnalyzeDurationKey, "200M" },
|
{ FfmpegAnalyzeDurationKey, "200M" },
|
||||||
{ BindToUnixSocketKey, bool.FalseString },
|
{ BindToUnixSocketKey, bool.FalseString },
|
||||||
{ SqliteCacheSizeKey, "20000" },
|
|
||||||
{ FfmpegSkipValidationKey, bool.FalseString },
|
{ FfmpegSkipValidationKey, bool.FalseString },
|
||||||
{ FfmpegImgExtractPerfTradeoffKey, bool.FalseString },
|
{ FfmpegImgExtractPerfTradeoffKey, bool.FalseString },
|
||||||
{ DetectNetworkChangeKey, bool.TrueString }
|
{ DetectNetworkChangeKey, bool.TrueString }
|
||||||
|
|||||||
@@ -108,14 +108,15 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
|
|||||||
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await using (context.ConfigureAwait(false))
|
await using (context.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
// Use the execution strategy to handle transactional consistency and retries
|
||||||
await using (transaction.ConfigureAwait(false))
|
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
||||||
|
var executionStrategy = context.Database.CreateExecutionStrategy();
|
||||||
|
await executionStrategy.ExecuteAsync(async () =>
|
||||||
{
|
{
|
||||||
await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||||
subProgress.Report(50);
|
subProgress.Report(50);
|
||||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
subProgress.Report(100);
|
subProgress.Report(100);
|
||||||
}
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;NU1510</NoWarn>
|
||||||
<ProjectGuid>{E383961B-9356-4D5D-8233-9A1079D03055}</ProjectGuid>
|
<ProjectGuid>{E383961B-9356-4D5D-8233-9A1079D03055}</ProjectGuid>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -27,8 +28,8 @@
|
|||||||
|
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
|
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
||||||
<PackageReference Include="prometheus-net.DotNetRuntime" />
|
<PackageReference Include="prometheus-net.DotNetRuntime" />
|
||||||
<PackageReference Include="DotNet.Glob" />
|
<PackageReference Include="DotNet.Glob" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -38,12 +39,14 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
|
||||||
<TargetFramework>net11.0</TargetFramework>
|
<TargetFramework>net11.0</TargetFramework>
|
||||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
|
||||||
<CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
|
<CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ namespace Emby.Server.Implementations.HttpServer
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Class WebSocketConnection.
|
/// Class WebSocketConnection.
|
||||||
|
/// Represents an authenticated WebSocket connection to a Jellyfin client.
|
||||||
|
///
|
||||||
|
/// Authentication is performed during connection establishment in WebSocketManager.
|
||||||
|
/// The client must provide a valid API token via one of these methods:
|
||||||
|
/// - Query parameter: ws://server:8096/socket?api_key=TOKEN
|
||||||
|
/// - Authorization header: MediaBrowser Token="..."
|
||||||
|
/// - Legacy headers: X-Emby-Token or X-MediaBrowser-Token
|
||||||
|
///
|
||||||
|
/// Once established, AuthorizationInfo contains the authenticated user/device information.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class WebSocketConnection : IWebSocketConnection
|
public class WebSocketConnection : IWebSocketConnection
|
||||||
{
|
{
|
||||||
@@ -47,7 +56,7 @@ namespace Emby.Server.Implementations.HttpServer
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="logger">The logger.</param>
|
/// <param name="logger">The logger.</param>
|
||||||
/// <param name="socket">The socket.</param>
|
/// <param name="socket">The socket.</param>
|
||||||
/// <param name="authorizationInfo">The authorization information.</param>
|
/// <param name="authorizationInfo">The authorization information containing authenticated user/device details.</param>
|
||||||
/// <param name="remoteEndPoint">The remote end point.</param>
|
/// <param name="remoteEndPoint">The remote end point.</param>
|
||||||
public WebSocketConnection(
|
public WebSocketConnection(
|
||||||
ILogger<WebSocketConnection> logger,
|
ILogger<WebSocketConnection> logger,
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Emby.Server.Implementations.HttpServer
|
namespace Emby.Server.Implementations.HttpServer
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Manages WebSocket connections with authentication support.
|
||||||
|
///
|
||||||
|
/// Clients should authenticate when connecting to WebSocket endpoints by providing an API token
|
||||||
|
/// through one of these methods:
|
||||||
|
///
|
||||||
|
/// 1. Query String Parameter (Recommended):
|
||||||
|
/// - URL: ws://jellyfin-server:8096/socket?api_key=YOUR_API_TOKEN
|
||||||
|
/// - JavaScript: const ws = new WebSocket(`ws://jellyfin-server:8096/socket?api_key=${token}`);
|
||||||
|
///
|
||||||
|
/// 2. Query String Parameter (Legacy):
|
||||||
|
/// - URL: ws://jellyfin-server:8096/socket?ApiKey=YOUR_API_TOKEN
|
||||||
|
///
|
||||||
|
/// 3. Authorization Header:
|
||||||
|
/// - Header: MediaBrowser Device="...", Token="YOUR_API_TOKEN"
|
||||||
|
///
|
||||||
|
/// The API token can be obtained from the server's API key management interface or
|
||||||
|
/// generated for specific clients/devices.
|
||||||
|
/// </summary>
|
||||||
public class WebSocketManager : IWebSocketManager
|
public class WebSocketManager : IWebSocketManager
|
||||||
{
|
{
|
||||||
private readonly IWebSocketListener[] _webSocketListeners;
|
private readonly IWebSocketListener[] _webSocketListeners;
|
||||||
|
|||||||
@@ -1223,29 +1223,26 @@ namespace Emby.Server.Implementations.Library
|
|||||||
private async Task RunPostScanTasks(IProgress<double> progress, CancellationToken cancellationToken)
|
private async Task RunPostScanTasks(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var tasks = PostScanTasks.ToList();
|
var tasks = PostScanTasks.ToList();
|
||||||
|
|
||||||
var numComplete = 0;
|
|
||||||
var numTasks = tasks.Count;
|
var numTasks = tasks.Count;
|
||||||
|
|
||||||
foreach (var task in tasks)
|
if (numTasks == 0)
|
||||||
{
|
{
|
||||||
// Prevent access to modified closure
|
_itemRepository.UpdateInheritedValues();
|
||||||
var currentNumComplete = numComplete;
|
progress.Report(100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressValues = new double[numTasks];
|
||||||
|
|
||||||
|
async Task RunOneTask(ILibraryPostScanTask task, int taskIndex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Running post-scan task {0}", task.GetType().Name);
|
||||||
var innerProgress = new Progress<double>(pct =>
|
var innerProgress = new Progress<double>(pct =>
|
||||||
{
|
{
|
||||||
double innerPercent = pct;
|
progressValues[taskIndex] = pct;
|
||||||
innerPercent /= 100;
|
progress.Report(progressValues.Average());
|
||||||
innerPercent += currentNumComplete;
|
|
||||||
|
|
||||||
innerPercent /= numTasks;
|
|
||||||
innerPercent *= 100;
|
|
||||||
|
|
||||||
progress.Report(innerPercent);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
_logger.LogDebug("Running post-scan task {0}", task.GetType().Name);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await task.Run(innerProgress, cancellationToken).ConfigureAwait(false);
|
await task.Run(innerProgress, cancellationToken).ConfigureAwait(false);
|
||||||
@@ -1260,12 +1257,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
_logger.LogError(ex, "Error running post-scan task");
|
_logger.LogError(ex, "Error running post-scan task");
|
||||||
}
|
}
|
||||||
|
|
||||||
numComplete++;
|
progressValues[taskIndex] = 100;
|
||||||
double percent = numComplete;
|
progress.Report(progressValues.Average());
|
||||||
percent /= numTasks;
|
|
||||||
progress.Report(percent * 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks.Select((task, i) => RunOneTask(task, i))).ConfigureAwait(false);
|
||||||
|
|
||||||
_itemRepository.UpdateInheritedValues();
|
_itemRepository.UpdateInheritedValues();
|
||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
@@ -1997,7 +1994,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken)
|
public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_itemRepository.SaveItems(items, cancellationToken);
|
_itemRepository.SaveItemsAsync(items, cancellationToken).GetAwaiter().GetResult();
|
||||||
|
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
@@ -2165,7 +2162,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
item.DateLastSaved = DateTime.UtcNow;
|
item.DateLastSaved = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
_itemRepository.SaveItems(items, cancellationToken);
|
await _itemRepository.SaveItemsAsync(items, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (parent is Folder folder)
|
if (parent is Folder folder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// <copyright file="LibraryOptionsRepository.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Library;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Extensions.Json;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Persistence;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository for persisting collection-folder library options in the database.
|
||||||
|
/// </summary>
|
||||||
|
public class LibraryOptionsRepository : ILibraryOptionsRepository
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||||
|
private readonly IServerApplicationHost _appHost;
|
||||||
|
private readonly ILogger<LibraryOptionsRepository> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="LibraryOptionsRepository"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dbProvider">The db context factory.</param>
|
||||||
|
/// <param name="appHost">The application host.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public LibraryOptionsRepository(
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
|
IServerApplicationHost appHost,
|
||||||
|
ILogger<LibraryOptionsRepository> logger)
|
||||||
|
{
|
||||||
|
_dbProvider = dbProvider;
|
||||||
|
_appHost = appHost;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public LibraryOptions GetLibraryOptions(string libraryPath)
|
||||||
|
=> GetLibraryOptionsAsync(libraryPath, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SaveLibraryOptions(string libraryPath, LibraryOptions options)
|
||||||
|
=> SaveLibraryOptionsAsync(libraryPath, options, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets library options from the database if the backing table is available.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryPath">The collection folder path.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The expanded library options, or <c>null</c> if no row exists.</returns>
|
||||||
|
public async Task<LibraryOptions> GetLibraryOptionsAsync(string libraryPath, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (context.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var entity = await context.LibraryOptions
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(e => e.LibraryPath == libraryPath, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = JsonSerializer.Deserialize<LibraryOptions>(entity.OptionsJson, JsonDefaults.Options) ?? new LibraryOptions();
|
||||||
|
|
||||||
|
foreach (var mediaPath in options.PathInfos)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(mediaPath.Path))
|
||||||
|
{
|
||||||
|
mediaPath.Path = _appHost.ExpandVirtualPath(mediaPath.Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (IsMissingTableException(ex))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "LibraryOptions table is not available yet. Falling back to XML for {LibraryPath}", libraryPath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error loading library options from database for {LibraryPath}", libraryPath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves library options to the database if the backing table is available.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryPath">The collection folder path.</param>
|
||||||
|
/// <param name="options">The options to save.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
public async Task SaveLibraryOptionsAsync(string libraryPath, LibraryOptions options, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (context.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var executionStrategy = context.Database.CreateExecutionStrategy();
|
||||||
|
await executionStrategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
var clone = JsonSerializer.Deserialize<LibraryOptions>(JsonSerializer.SerializeToUtf8Bytes(options, JsonDefaults.Options), JsonDefaults.Options)
|
||||||
|
?? new LibraryOptions();
|
||||||
|
|
||||||
|
foreach (var mediaPath in clone.PathInfos)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(mediaPath.Path))
|
||||||
|
{
|
||||||
|
mediaPath.Path = _appHost.ReverseVirtualPath(mediaPath.Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = await context.LibraryOptions
|
||||||
|
.SingleOrDefaultAsync(e => e.LibraryPath == libraryPath, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var utcNow = DateTime.UtcNow;
|
||||||
|
var json = JsonSerializer.Serialize(clone, JsonDefaults.Options);
|
||||||
|
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
entity = new LibraryOptionsEntity
|
||||||
|
{
|
||||||
|
LibraryPath = libraryPath,
|
||||||
|
OptionsJson = json,
|
||||||
|
Version = 1,
|
||||||
|
DateCreated = utcNow,
|
||||||
|
DateModified = utcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await context.LibraryOptions.AddAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entity.OptionsJson = json;
|
||||||
|
entity.DateModified = utcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (IsMissingTableException(ex))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "LibraryOptions table is not available yet. Skipping database save for {LibraryPath}", libraryPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error saving library options to database for {LibraryPath}", libraryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMissingTableException(Exception exception)
|
||||||
|
{
|
||||||
|
return exception.Message.Contains("LibraryOptions", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (exception.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| exception.Message.Contains("no such table", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,24 +62,28 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var keys = item.GetUserDataKeys();
|
var keys = item.GetUserDataKeys();
|
||||||
|
|
||||||
using var dbContext = _repository.CreateDbContext();
|
using var dbContext = _repository.CreateDbContext();
|
||||||
using var transaction = dbContext.Database.BeginTransaction();
|
var executionStrategy = dbContext.Database.CreateExecutionStrategy();
|
||||||
|
executionStrategy.Execute(() =>
|
||||||
foreach (var key in keys)
|
|
||||||
{
|
{
|
||||||
userData.Key = key;
|
using var transaction = dbContext.Database.BeginTransaction();
|
||||||
var userDataEntry = Map(userData, user.Id, item.Id);
|
|
||||||
if (dbContext.UserData.Any(f => f.ItemId == userDataEntry.ItemId && f.UserId == userDataEntry.UserId && f.CustomDataKey == userDataEntry.CustomDataKey))
|
|
||||||
{
|
|
||||||
dbContext.UserData.Attach(userDataEntry).State = EntityState.Modified;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
dbContext.UserData.Add(userDataEntry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dbContext.SaveChanges();
|
foreach (var key in keys)
|
||||||
transaction.Commit();
|
{
|
||||||
|
userData.Key = key;
|
||||||
|
var userDataEntry = Map(userData, user.Id, item.Id);
|
||||||
|
if (dbContext.UserData.Any(f => f.ItemId == userDataEntry.ItemId && f.UserId == userDataEntry.UserId && f.CustomDataKey == userDataEntry.CustomDataKey))
|
||||||
|
{
|
||||||
|
dbContext.UserData.Attach(userDataEntry).State = EntityState.Modified;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dbContext.UserData.Add(userDataEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.SaveChanges();
|
||||||
|
transaction.Commit();
|
||||||
|
});
|
||||||
|
|
||||||
var userId = user.InternalId;
|
var userId = user.InternalId;
|
||||||
var cacheKey = GetCacheKey(userId, item.Id);
|
var cacheKey = GetCacheKey(userId, item.Id);
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList();
|
var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList();
|
||||||
var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews);
|
var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews);
|
||||||
|
|
||||||
|
// Create a lookup dictionary for efficient and reliable ordering
|
||||||
|
var sortedIndexLookup = sorted
|
||||||
|
.Select((item, index) => new { item, index })
|
||||||
|
.ToDictionary(x => x.item.Id, x => x.index);
|
||||||
|
|
||||||
return list
|
return list
|
||||||
.OrderBy(i =>
|
.OrderBy(i =>
|
||||||
{
|
{
|
||||||
@@ -158,7 +163,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
return index == -1 ? int.MaxValue : index;
|
return index == -1 ? int.MaxValue : index;
|
||||||
})
|
})
|
||||||
.ThenBy(sorted.IndexOf)
|
.ThenBy(i => sortedIndexLookup.TryGetValue(i.Id, out var sortIndex) ? sortIndex : int.MaxValue)
|
||||||
.ThenBy(i => i.SortName)
|
.ThenBy(i => i.SortName)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
// <copyright file="DatabaseViewsController.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Controllers;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Database.Implementations.Entities.Views;
|
||||||
|
using MediaBrowser.Common.Api;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposes the PostgreSQL reporting views as read-only API endpoints.
|
||||||
|
/// All endpoints require administrator privileges.
|
||||||
|
/// </summary>
|
||||||
|
[Route("DatabaseViews")]
|
||||||
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
|
public class DatabaseViewsController : BaseJellyfinApiController
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DatabaseViewsController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dbFactory">The EF Core context factory.</param>
|
||||||
|
public DatabaseViewsController(IDbContextFactory<JellyfinDbContext> dbFactory)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a flat list of all media streams (audio + video columns merged).
|
||||||
|
/// Maps to library."MediaStreamInfos_v".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">Optional item ID filter.</param>
|
||||||
|
/// <param name="streamType">Optional stream type filter (0=Audio, 1=Video, 2=Subtitle).</param>
|
||||||
|
/// <param name="startIndex">Record index to start at.</param>
|
||||||
|
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
|
||||||
|
/// <returns>List of media stream rows.</returns>
|
||||||
|
[HttpGet("MediaStreamInfos")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IEnumerable<MediaStreamInfoView>>> GetMediaStreamInfos(
|
||||||
|
[FromQuery] Guid? itemId = null,
|
||||||
|
[FromQuery] int? streamType = null,
|
||||||
|
[FromQuery] int startIndex = 0,
|
||||||
|
[FromQuery] int limit = 100)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit, 1, 1000);
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
var query = db.MediaStreamInfoViews.AsNoTracking();
|
||||||
|
if (itemId.HasValue)
|
||||||
|
{
|
||||||
|
var id = itemId.Value;
|
||||||
|
query = query.Where(x => x.ItemId.HasValue && x.ItemId.Value.Equals(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamType.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.StreamType == streamType.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<MediaStreamInfoView> results = await query
|
||||||
|
.OrderBy(x => x.ItemId)
|
||||||
|
.ThenBy(x => x.StreamIndex)
|
||||||
|
.Skip(startIndex)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Ok(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets video items joined with primary stream technical details.
|
||||||
|
/// Maps to library."VideoItems_v".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">Optional partial type filter (e.g. "Movie", "Episode").</param>
|
||||||
|
/// <param name="minHeight">Optional minimum video height (e.g. 2160 for 4K).</param>
|
||||||
|
/// <param name="hdrFormat">Optional HDR format ("Dolby Vision", "HDR10+", "HDR10", "HLG", "SDR").</param>
|
||||||
|
/// <param name="startIndex">Record index to start at.</param>
|
||||||
|
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
|
||||||
|
/// <returns>List of video item rows.</returns>
|
||||||
|
[HttpGet("VideoItems")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IEnumerable<VideoItemView>>> GetVideoItems(
|
||||||
|
[FromQuery] string? type = null,
|
||||||
|
[FromQuery] int? minHeight = null,
|
||||||
|
[FromQuery] string? hdrFormat = null,
|
||||||
|
[FromQuery] int startIndex = 0,
|
||||||
|
[FromQuery] int limit = 100)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit, 1, 1000);
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
var query = db.VideoItemViews.AsNoTracking();
|
||||||
|
if (!string.IsNullOrEmpty(type))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Type != null && x.Type.Contains(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minHeight.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Height >= minHeight.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(hdrFormat))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.HDRFormat == hdrFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<VideoItemView> results = await query
|
||||||
|
.OrderBy(x => x.Name)
|
||||||
|
.Skip(startIndex)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Ok(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets per-user playback history with calculated progress percentages.
|
||||||
|
/// Maps to library."UserPlaybackHistory_v".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">Optional exact username filter.</param>
|
||||||
|
/// <param name="itemId">Optional item ID filter.</param>
|
||||||
|
/// <param name="inProgressOnly">When true, returns only items started but not fully played.</param>
|
||||||
|
/// <param name="startIndex">Record index to start at.</param>
|
||||||
|
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
|
||||||
|
/// <returns>List of playback history rows.</returns>
|
||||||
|
[HttpGet("PlaybackHistory")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IEnumerable<UserPlaybackHistoryView>>> GetPlaybackHistory(
|
||||||
|
[FromQuery] string? username = null,
|
||||||
|
[FromQuery] Guid? itemId = null,
|
||||||
|
[FromQuery] bool inProgressOnly = false,
|
||||||
|
[FromQuery] int startIndex = 0,
|
||||||
|
[FromQuery] int limit = 100)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit, 1, 1000);
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
var query = db.UserPlaybackHistoryViews.AsNoTracking();
|
||||||
|
if (!string.IsNullOrEmpty(username))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Username == username);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemId.HasValue)
|
||||||
|
{
|
||||||
|
var id = itemId.Value;
|
||||||
|
query = query.Where(x => x.ItemId.HasValue && x.ItemId.Value.Equals(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inProgressOnly)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Played != true && x.ProgressPct > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<UserPlaybackHistoryView> results = await query
|
||||||
|
.OrderByDescending(x => x.LastPlayedDate)
|
||||||
|
.Skip(startIndex)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Ok(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets items with their external provider IDs (IMDb, TMDB, TVDB, etc.) in named columns.
|
||||||
|
/// Maps to library."ItemProviders_v".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">Optional partial type filter.</param>
|
||||||
|
/// <param name="imdbId">Optional exact IMDb ID filter.</param>
|
||||||
|
/// <param name="tmdbId">Optional exact TMDB ID filter.</param>
|
||||||
|
/// <param name="tvdbId">Optional exact TVDB ID filter.</param>
|
||||||
|
/// <param name="startIndex">Record index to start at.</param>
|
||||||
|
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
|
||||||
|
/// <returns>List of item provider rows.</returns>
|
||||||
|
[HttpGet("ItemProviders")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IEnumerable<ItemProviderView>>> GetItemProviders(
|
||||||
|
[FromQuery] string? type = null,
|
||||||
|
[FromQuery] string? imdbId = null,
|
||||||
|
[FromQuery] string? tmdbId = null,
|
||||||
|
[FromQuery] string? tvdbId = null,
|
||||||
|
[FromQuery] int startIndex = 0,
|
||||||
|
[FromQuery] int limit = 100)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit, 1, 1000);
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
var query = db.ItemProviderViews.AsNoTracking();
|
||||||
|
if (!string.IsNullOrEmpty(type))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Type != null && x.Type.Contains(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(imdbId))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.ImdbId == imdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tmdbId))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.TmdbId == tmdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tvdbId))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.TvdbId == tvdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<ItemProviderView> results = await query
|
||||||
|
.OrderBy(x => x.Name)
|
||||||
|
.Skip(startIndex)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Ok(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets cast and crew entries linked to the items they appear in.
|
||||||
|
/// Maps to library."ItemPeople_v".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="personName">Optional exact person name filter.</param>
|
||||||
|
/// <param name="personType">Optional person type filter (e.g. "Actor", "Director").</param>
|
||||||
|
/// <param name="itemId">Optional item ID filter.</param>
|
||||||
|
/// <param name="startIndex">Record index to start at.</param>
|
||||||
|
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
|
||||||
|
/// <returns>List of item person rows.</returns>
|
||||||
|
[HttpGet("ItemPeople")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IEnumerable<ItemPersonView>>> GetItemPeople(
|
||||||
|
[FromQuery] string? personName = null,
|
||||||
|
[FromQuery] string? personType = null,
|
||||||
|
[FromQuery] Guid? itemId = null,
|
||||||
|
[FromQuery] int startIndex = 0,
|
||||||
|
[FromQuery] int limit = 100)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit, 1, 1000);
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
var query = db.ItemPersonViews.AsNoTracking();
|
||||||
|
if (!string.IsNullOrEmpty(personName))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.PersonName == personName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(personType))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.PersonType == personType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemId.HasValue)
|
||||||
|
{
|
||||||
|
var id = itemId.Value;
|
||||||
|
query = query.Where(x => x.ItemId.HasValue && x.ItemId.Value.Equals(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<ItemPersonView> results = await query
|
||||||
|
.OrderBy(x => x.PersonName)
|
||||||
|
.ThenBy(x => x.SortOrder)
|
||||||
|
.Skip(startIndex)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Ok(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets aggregate library statistics - one row per item type.
|
||||||
|
/// Maps to library."LibrarySummary_v".
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>List of library summary rows, one per item type.</returns>
|
||||||
|
[HttpGet("LibrarySummary")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IEnumerable<LibrarySummaryView>>> GetLibrarySummary()
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
IEnumerable<LibrarySummaryView> results = await db.LibrarySummaryViews
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderBy(x => x.Type)
|
||||||
|
.ToListAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Ok(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
|
||||||
<ProjectGuid>{DFBEFB4C-DA19-4143-98B7-27320C7F7163}</ProjectGuid>
|
<ProjectGuid>{DFBEFB4C-DA19-4143-98B7-27320C7F7163}</ProjectGuid>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
|
||||||
<TargetFramework>net11.0</TargetFramework>
|
<TargetFramework>net11.0</TargetFramework>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -14,6 +16,7 @@
|
|||||||
<PackageReference Include="Jellyfin.Sdk" />
|
<PackageReference Include="Jellyfin.Sdk" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" />
|
<PackageReference Include="Microsoft.AspNetCore.Authorization" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||||
|
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.ReDoc" />
|
<PackageReference Include="Swashbuckle.AspNetCore.ReDoc" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -83,11 +83,22 @@ public class ExceptionMiddleware
|
|||||||
{
|
{
|
||||||
if (isAuthenticationError)
|
if (isAuthenticationError)
|
||||||
{
|
{
|
||||||
// Log authentication errors as warnings with user-friendly message
|
// WebSocket authentication failures are expected when clients connect without tokens
|
||||||
_logger.LogWarning(
|
// Log at Debug level to reduce noise, unless it's not a WebSocket endpoint
|
||||||
"Access denied: Unable to process request. Authentication token missing or invalid. URL {Method} {Url}.",
|
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase))
|
||||||
context.Request.Method,
|
{
|
||||||
context.Request.Path);
|
_logger.LogDebug(
|
||||||
|
"WebSocket connection rejected: Authentication token missing or invalid. IP: {IP}",
|
||||||
|
context.Connection.RemoteIpAddress);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Log authentication errors for non-WebSocket endpoints as warnings
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Access denied: Unable to process request. Authentication token missing or invalid. URL {Method} {Url}.",
|
||||||
|
context.Request.Method,
|
||||||
|
context.Request.Path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// <copyright file="WebSocketAuthenticationMiddleware.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Middleware;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Api.Constants;
|
||||||
|
using Jellyfin.Data;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using MediaBrowser.Controller.Net;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WebSocket Authentication Middleware.
|
||||||
|
/// Extracts and validates API tokens from WebSocket query strings.
|
||||||
|
/// </summary>
|
||||||
|
public class WebSocketAuthenticationMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<WebSocketAuthenticationMiddleware> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="WebSocketAuthenticationMiddleware"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="next">The next delegate in the pipeline.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public WebSocketAuthenticationMiddleware(RequestDelegate next, ILogger<WebSocketAuthenticationMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the middleware action.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpContext">The current HTTP context.</param>
|
||||||
|
/// <param name="authService">The authentication service.</param>
|
||||||
|
/// <returns>The async task.</returns>
|
||||||
|
public async Task Invoke(HttpContext httpContext, IAuthService authService)
|
||||||
|
{
|
||||||
|
// Only process WebSocket upgrade requests on the /socket endpoint
|
||||||
|
if (httpContext.WebSockets.IsWebSocketRequest
|
||||||
|
&& httpContext.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await AuthenticateWebSocketRequest(httpContext, authService).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(httpContext).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates WebSocket requests by extracting api_key from query string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpContext">The HTTP context.</param>
|
||||||
|
/// <param name="authService">The authentication service.</param>
|
||||||
|
/// <returns>The async task.</returns>
|
||||||
|
private async Task AuthenticateWebSocketRequest(HttpContext httpContext, IAuthService authService)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Extract api_key from query string
|
||||||
|
if (!httpContext.Request.Query.TryGetValue("api_key", out var apiKeyValues) || apiKeyValues.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"WebSocket connection attempted without api_key query parameter. IP: {IP}",
|
||||||
|
httpContext.Connection.RemoteIpAddress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiKey = apiKeyValues[0];
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"WebSocket connection attempted with empty api_key. IP: {IP}",
|
||||||
|
httpContext.Connection.RemoteIpAddress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate using the provided api_key
|
||||||
|
var authorizationInfo = await authService.Authenticate(httpContext.Request).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!authorizationInfo.HasToken)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"WebSocket authentication failed: Invalid or expired token. IP: {IP}",
|
||||||
|
httpContext.Connection.RemoteIpAddress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the authenticated user in the context for downstream middleware
|
||||||
|
var role = UserRoles.User;
|
||||||
|
if (authorizationInfo.IsApiKey
|
||||||
|
|| (authorizationInfo.User?.HasPermission(PermissionKind.IsAdministrator) ?? false))
|
||||||
|
{
|
||||||
|
role = UserRoles.Administrator;
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.Name, authorizationInfo.User?.Username ?? string.Empty),
|
||||||
|
new Claim(ClaimTypes.Role, role),
|
||||||
|
new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
|
||||||
|
new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId ?? string.Empty),
|
||||||
|
new Claim(InternalClaimTypes.Device, authorizationInfo.Device ?? string.Empty),
|
||||||
|
new Claim(InternalClaimTypes.Client, authorizationInfo.Client ?? string.Empty),
|
||||||
|
new Claim(InternalClaimTypes.Version, authorizationInfo.Version ?? string.Empty),
|
||||||
|
new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
|
||||||
|
new Claim(InternalClaimTypes.IsApiKey, authorizationInfo.IsApiKey.ToString(CultureInfo.InvariantCulture))
|
||||||
|
};
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, "WebSocket");
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
httpContext.User = principal;
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"WebSocket authentication successful for user {Username}. IP: {IP}",
|
||||||
|
authorizationInfo.User?.Username ?? "Unknown",
|
||||||
|
httpContext.Connection.RemoteIpAddress);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
ex,
|
||||||
|
"Error during WebSocket authentication. IP: {IP}",
|
||||||
|
httpContext.Connection.RemoteIpAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// <copyright file="WebSocketAuthenticationMiddlewareExtensions.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Middleware;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for adding WebSocket authentication to the pipeline.
|
||||||
|
/// </summary>
|
||||||
|
public static class WebSocketAuthenticationMiddlewareExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds WebSocket authentication to the application pipeline.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="appBuilder">The application builder.</param>
|
||||||
|
/// <returns>The updated application builder.</returns>
|
||||||
|
public static IApplicationBuilder UseWebSocketAuthentication(this IApplicationBuilder appBuilder)
|
||||||
|
{
|
||||||
|
return appBuilder.UseMiddleware<WebSocketAuthenticationMiddleware>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
|
||||||
<TargetFramework>net11.0</TargetFramework>
|
<TargetFramework>net11.0</TargetFramework>
|
||||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
@@ -11,11 +12,13 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
|
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
|
||||||
<!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
|
<!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
|
||||||
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
|
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Data</PackageId>
|
<PackageId>Jellyfin.Data</PackageId>
|
||||||
<VersionPrefix>10.12.0</VersionPrefix>
|
<VersionPrefix>10.12.0</VersionPrefix>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ using MediaBrowser.Controller.Configuration;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
|
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -25,6 +26,35 @@ using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ServiceCollectionExtensions
|
public static class ServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether detailed EF Core logging should be enabled based on the configured log level.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configuration">The application configuration.</param>
|
||||||
|
/// <returns>True if the log level for Microsoft.EntityFrameworkCore.Database.Command is Debug or lower; otherwise false.</returns>
|
||||||
|
private static bool ShouldEnableDetailedEFCoreLogging(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
// Check if detailed EF Core logging is explicitly enabled in configuration
|
||||||
|
// Read the log level override for Entity Framework Core command logging
|
||||||
|
var efCoreLogLevelString = configuration["Serilog:MinimumLevel:Override:Microsoft.EntityFrameworkCore.Database.Command"];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(efCoreLogLevelString))
|
||||||
|
{
|
||||||
|
// If not explicitly set, check the default minimum level
|
||||||
|
efCoreLogLevelString = configuration["Serilog:MinimumLevel:Default"] ?? "Information";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the log level string to LogLevel enum
|
||||||
|
if (Enum.TryParse<LogLevel>(efCoreLogLevelString, ignoreCase: true, out var logLevel))
|
||||||
|
{
|
||||||
|
// Enable detailed logging if the level is Debug or lower (more verbose)
|
||||||
|
// LogLevel order: Trace=0, Debug=1, Information=2, Warning=3, Error=4, Critical=5, None=6
|
||||||
|
return logLevel <= LogLevel.Debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to false if we can't parse the log level
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static IEnumerable<Type> DatabaseProviderTypes()
|
private static IEnumerable<Type> DatabaseProviderTypes()
|
||||||
{
|
{
|
||||||
yield return typeof(PostgresDatabaseProvider);
|
yield return typeof(PostgresDatabaseProvider);
|
||||||
@@ -99,23 +129,13 @@ public static class ServiceCollectionExtensions
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// when nothing is setup via new Database configuration, fallback to SQLite with default settings.
|
// PostgreSQL-only build: default to PostgreSQL when no database.xml exists.
|
||||||
efCoreConfiguration = new DatabaseConfigurationOptions()
|
efCoreConfiguration = new DatabaseConfigurationOptions()
|
||||||
{
|
{
|
||||||
DatabaseType = "Jellyfin-SQLite",
|
DatabaseType = "Jellyfin-PostgreSQL",
|
||||||
LockingBehavior = DatabaseLockingBehaviorTypes.NoLock,
|
LockingBehavior = DatabaseLockingBehaviorTypes.NoLock,
|
||||||
PresetConfigurations =
|
PresetConfigurations =
|
||||||
[
|
[
|
||||||
new PresetConfigurationItem
|
|
||||||
{
|
|
||||||
Key = "SQLite",
|
|
||||||
Value = new PresetDatabaseConfiguration
|
|
||||||
{
|
|
||||||
DatabaseType = "Jellyfin-SQLite",
|
|
||||||
LockingBehavior = DatabaseLockingBehaviorTypes.NoLock,
|
|
||||||
Description = "Default SQLite database configuration. Database stored in data directory."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
new PresetConfigurationItem
|
new PresetConfigurationItem
|
||||||
{
|
{
|
||||||
Key = "PostgreSQL-Local",
|
Key = "PostgreSQL-Local",
|
||||||
@@ -135,7 +155,7 @@ public static class ServiceCollectionExtensions
|
|||||||
// Ensure DatabaseType is set (handle corrupted or empty configuration files)
|
// Ensure DatabaseType is set (handle corrupted or empty configuration files)
|
||||||
if (string.IsNullOrWhiteSpace(efCoreConfiguration.DatabaseType))
|
if (string.IsNullOrWhiteSpace(efCoreConfiguration.DatabaseType))
|
||||||
{
|
{
|
||||||
efCoreConfiguration.DatabaseType = "Jellyfin-SQLite";
|
efCoreConfiguration.DatabaseType = "Jellyfin-PostgreSQL";
|
||||||
configurationManager.SaveConfiguration("database", efCoreConfiguration);
|
configurationManager.SaveConfiguration("database", efCoreConfiguration);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,13 +199,15 @@ public static class ServiceCollectionExtensions
|
|||||||
var lockingBehavior = serviceProvider.GetRequiredService<IEntityFrameworkCoreLockingBehavior>();
|
var lockingBehavior = serviceProvider.GetRequiredService<IEntityFrameworkCoreLockingBehavior>();
|
||||||
lockingBehavior.Initialise(opt);
|
lockingBehavior.Initialise(opt);
|
||||||
|
|
||||||
// Enable SQL query logging when log level is Debug
|
|
||||||
var loggerFactory = serviceProvider.GetService<Microsoft.Extensions.Logging.ILoggerFactory>();
|
var loggerFactory = serviceProvider.GetService<Microsoft.Extensions.Logging.ILoggerFactory>();
|
||||||
if (loggerFactory != null)
|
opt.UseLoggerFactory(loggerFactory);
|
||||||
|
|
||||||
|
// Only enable sensitive data logging and detailed errors if the EF Core command log level
|
||||||
|
// is set to Debug or lower, respecting the logging configuration from logging.json
|
||||||
|
if (ShouldEnableDetailedEFCoreLogging(configuration))
|
||||||
{
|
{
|
||||||
opt.UseLoggerFactory(loggerFactory)
|
opt.EnableSensitiveDataLogging()
|
||||||
.EnableSensitiveDataLogging()
|
.EnableDetailedErrors();
|
||||||
.EnableDetailedErrors();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ using System.Text.Json.Serialization;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Server.Implementations.Migrations;
|
||||||
using Jellyfin.Server.Implementations.StorageHelpers;
|
using Jellyfin.Server.Implementations.StorageHelpers;
|
||||||
using Jellyfin.Server.Implementations.SystemBackupService;
|
using Jellyfin.Server.Implementations.SystemBackupService;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Controller.SystemBackupService;
|
using MediaBrowser.Controller.SystemBackupService;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -156,34 +156,31 @@ public class BackupService : IBackupService
|
|||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
// restore migration history manually
|
// restore migration history manually
|
||||||
var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(HistoryRow)}.json")));
|
var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{MigrationTracker.BackupEntryName}.json")));
|
||||||
if (historyEntry is null)
|
if (historyEntry is null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
|
_logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
|
||||||
throw new InvalidOperationException("Cannot restore backup that has no History data.");
|
throw new InvalidOperationException("Cannot restore backup that has no History data.");
|
||||||
}
|
}
|
||||||
|
|
||||||
HistoryRow[] historyEntries;
|
MigrationRecord[] historyEntries;
|
||||||
var historyArchive = await historyEntry.OpenAsync().ConfigureAwait(false);
|
var historyArchive = await historyEntry.OpenAsync().ConfigureAwait(false);
|
||||||
await using (historyArchive.ConfigureAwait(false))
|
await using (historyArchive.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
|
historyEntries = await JsonSerializer.DeserializeAsync<MigrationRecord[]>(historyArchive).ConfigureAwait(false) ??
|
||||||
throw new InvalidOperationException("Cannot restore backup that has no History data.");
|
throw new InvalidOperationException("Cannot restore backup that has no History data.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
|
||||||
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
foreach (var item in await historyRepository.GetAppliedMigrationsAsync(CancellationToken.None).ConfigureAwait(false))
|
foreach (var item in await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext, CancellationToken.None).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var insertScript = historyRepository.GetDeleteScript(item.MigrationId);
|
await MigrationTracker.DeleteAsync(dbContext, item).ConfigureAwait(false);
|
||||||
await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var item in historyEntries)
|
foreach (var item in historyEntries)
|
||||||
{
|
{
|
||||||
var insertScript = historyRepository.GetInsertScript(item);
|
await MigrationTracker.InsertAsync(dbContext, item.MigrationId, item.ProductVersion).ConfigureAwait(false);
|
||||||
await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
||||||
@@ -310,8 +307,7 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// include the migration history as well
|
// include the migration history as well
|
||||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
var migrations = await MigrationTracker.GetAppliedMigrationsAsync(dbContext).ConfigureAwait(false);
|
||||||
var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
|
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
|
||||||
[
|
[
|
||||||
@@ -319,12 +315,12 @@ public class BackupService : IBackupService
|
|||||||
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||||
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
|
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
|
||||||
.Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
|
.Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
|
||||||
(Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
|
(Type: typeof(MigrationRecord), SourceName: MigrationTracker.BackupEntryName, ValueFactory: () => migrations.ToAsyncEnumerable())
|
||||||
];
|
];
|
||||||
manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
|
manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
|
||||||
var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using (transaction.ConfigureAwait(false))
|
var executionStrategy = dbContext.Database.CreateExecutionStrategy();
|
||||||
|
await executionStrategy.ExecuteAsync(async () =>
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Begin Database backup");
|
_logger.LogInformation("Begin Database backup");
|
||||||
|
|
||||||
@@ -363,7 +359,7 @@ public class BackupService : IBackupService
|
|||||||
|
|
||||||
_logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
|
_logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
|
||||||
}
|
}
|
||||||
}
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
|
_logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -77,21 +77,25 @@ public class ChapterRepository : IChapterRepository
|
|||||||
public async Task SaveChaptersAsync(Guid itemId, IReadOnlyList<ChapterInfo> chapters, CancellationToken cancellationToken = default)
|
public async Task SaveChaptersAsync(Guid itemId, IReadOnlyList<ChapterInfo> chapters, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await using var context = _dbProvider.CreateDbContext();
|
await using var context = _dbProvider.CreateDbContext();
|
||||||
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await context.Chapters
|
// Use the execution strategy to handle transactional consistency and retries
|
||||||
.Where(e => e.ItemId.Equals(itemId))
|
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
||||||
.ExecuteDeleteAsync(cancellationToken)
|
var executionStrategy = context.Database.CreateExecutionStrategy();
|
||||||
.ConfigureAwait(false);
|
await executionStrategy.ExecuteAsync(async () =>
|
||||||
|
|
||||||
for (var i = 0; i < chapters.Count; i++)
|
|
||||||
{
|
{
|
||||||
var chapter = chapters[i];
|
await context.Chapters
|
||||||
await context.Chapters.AddAsync(Map(chapter, i, itemId), cancellationToken).ConfigureAwait(false);
|
.Where(e => e.ItemId.Equals(itemId))
|
||||||
}
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
for (var i = 0; i < chapters.Count; i++)
|
||||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
{
|
||||||
|
var chapter = chapters[i];
|
||||||
|
await context.Chapters.AddAsync(Map(chapter, i, itemId), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -64,12 +64,16 @@ public class KeyframeRepository : IKeyframeRepository
|
|||||||
public async Task SaveKeyframeDataAsync(Guid itemId, MediaEncoding.Keyframes.KeyframeData data, CancellationToken cancellationToken = default)
|
public async Task SaveKeyframeDataAsync(Guid itemId, MediaEncoding.Keyframes.KeyframeData data, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await using var context = _dbProvider.CreateDbContext();
|
await using var context = _dbProvider.CreateDbContext();
|
||||||
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
// Use the execution strategy to handle transactional consistency and retries
|
||||||
await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false);
|
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
var executionStrategy = context.Database.CreateExecutionStrategy();
|
||||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
await executionStrategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -29,25 +29,29 @@ public class MediaAttachmentRepository(IDbContextFactory<JellyfinDbContext> dbPr
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await using var context = dbProvider.CreateDbContext();
|
await using var context = dbProvider.CreateDbContext();
|
||||||
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Users may replace a media with a version that includes attachments to one without them.
|
// Use the execution strategy to handle transactional consistency and retries
|
||||||
// So when saving attachments is triggered by a library scan, we always unconditionally
|
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
||||||
// clear the old ones, and then add the new ones if given.
|
var executionStrategy = context.Database.CreateExecutionStrategy();
|
||||||
await context.AttachmentStreamInfos
|
await executionStrategy.ExecuteAsync(async () =>
|
||||||
.Where(e => e.ItemId.Equals(id))
|
|
||||||
.ExecuteDeleteAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (attachments.Any())
|
|
||||||
{
|
{
|
||||||
|
// Users may replace a media with a version that includes attachments to one without them.
|
||||||
|
// So when saving attachments is triggered by a library scan, we always unconditionally
|
||||||
|
// clear the old ones, and then add the new ones if given.
|
||||||
await context.AttachmentStreamInfos
|
await context.AttachmentStreamInfos
|
||||||
.AddRangeAsync(attachments.Select(e => Map(e, id)), cancellationToken)
|
.Where(e => e.ItemId.Equals(id))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
|
||||||
|
|
||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
if (attachments.Any())
|
||||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
{
|
||||||
|
await context.AttachmentStreamInfos
|
||||||
|
.AddRangeAsync(attachments.Select(e => Map(e, id)), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -44,19 +44,23 @@ public class MediaStreamRepository : IMediaStreamRepository
|
|||||||
public async Task SaveMediaStreamsAsync(Guid id, IReadOnlyList<MediaStream> streams, CancellationToken cancellationToken = default)
|
public async Task SaveMediaStreamsAsync(Guid id, IReadOnlyList<MediaStream> streams, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await using var context = _dbProvider.CreateDbContext();
|
await using var context = _dbProvider.CreateDbContext();
|
||||||
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await context.MediaStreamInfos
|
// Use the execution strategy to handle transactional consistency and retries
|
||||||
.Where(e => e.ItemId.Equals(id))
|
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
||||||
.ExecuteDeleteAsync(cancellationToken)
|
var executionStrategy = context.Database.CreateExecutionStrategy();
|
||||||
.ConfigureAwait(false);
|
await executionStrategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
await context.MediaStreamInfos
|
||||||
|
.Where(e => e.ItemId.Equals(id))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await context.MediaStreamInfos
|
await context.MediaStreamInfos
|
||||||
.AddRangeAsync(streams.Select(f => Map(f, id)), cancellationToken)
|
.AddRangeAsync(streams.Select(f => Map(f, id)), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -64,6 +68,8 @@ public class MediaStreamRepository : IMediaStreamRepository
|
|||||||
{
|
{
|
||||||
await using var context = _dbProvider.CreateDbContext();
|
await using var context = _dbProvider.CreateDbContext();
|
||||||
var streams = await TranslateQuery(context.MediaStreamInfos.AsNoTracking(), filter)
|
var streams = await TranslateQuery(context.MediaStreamInfos.AsNoTracking(), filter)
|
||||||
|
.Include(e => e.AudioDetails)
|
||||||
|
.Include(e => e.VideoDetails)
|
||||||
.ToArrayAsync(cancellationToken)
|
.ToArrayAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -127,46 +133,54 @@ public class MediaStreamRepository : IMediaStreamRepository
|
|||||||
|
|
||||||
dto.Language = language;
|
dto.Language = language;
|
||||||
|
|
||||||
dto.ChannelLayout = entity.ChannelLayout;
|
|
||||||
dto.Profile = entity.Profile;
|
dto.Profile = entity.Profile;
|
||||||
dto.AspectRatio = entity.AspectRatio;
|
|
||||||
dto.Path = RestorePath(entity.Path);
|
dto.Path = RestorePath(entity.Path);
|
||||||
dto.IsInterlaced = entity.IsInterlaced.GetValueOrDefault();
|
|
||||||
dto.BitRate = entity.BitRate;
|
dto.BitRate = entity.BitRate;
|
||||||
dto.Channels = entity.Channels;
|
|
||||||
dto.SampleRate = entity.SampleRate;
|
|
||||||
dto.IsDefault = entity.IsDefault;
|
dto.IsDefault = entity.IsDefault;
|
||||||
dto.IsForced = entity.IsForced;
|
dto.IsForced = entity.IsForced;
|
||||||
dto.IsExternal = entity.IsExternal;
|
dto.IsExternal = entity.IsExternal;
|
||||||
dto.Height = entity.Height;
|
|
||||||
dto.Width = entity.Width;
|
|
||||||
dto.AverageFrameRate = entity.AverageFrameRate;
|
|
||||||
dto.RealFrameRate = entity.RealFrameRate;
|
|
||||||
dto.Level = entity.Level;
|
dto.Level = entity.Level;
|
||||||
dto.PixelFormat = entity.PixelFormat;
|
|
||||||
dto.BitDepth = entity.BitDepth;
|
|
||||||
dto.IsAnamorphic = entity.IsAnamorphic;
|
|
||||||
dto.RefFrames = entity.RefFrames;
|
|
||||||
dto.CodecTag = entity.CodecTag;
|
dto.CodecTag = entity.CodecTag;
|
||||||
dto.Comment = entity.Comment;
|
dto.Comment = entity.Comment;
|
||||||
dto.NalLengthSize = entity.NalLengthSize;
|
|
||||||
dto.Title = entity.Title;
|
dto.Title = entity.Title;
|
||||||
dto.TimeBase = entity.TimeBase;
|
dto.TimeBase = entity.TimeBase;
|
||||||
dto.CodecTimeBase = entity.CodecTimeBase;
|
dto.CodecTimeBase = entity.CodecTimeBase;
|
||||||
dto.ColorPrimaries = entity.ColorPrimaries;
|
|
||||||
dto.ColorSpace = entity.ColorSpace;
|
if (entity.AudioDetails is { } audio)
|
||||||
dto.ColorTransfer = entity.ColorTransfer;
|
{
|
||||||
dto.DvVersionMajor = entity.DvVersionMajor;
|
dto.ChannelLayout = audio.ChannelLayout;
|
||||||
dto.DvVersionMinor = entity.DvVersionMinor;
|
dto.Channels = audio.Channels;
|
||||||
dto.DvProfile = entity.DvProfile;
|
dto.SampleRate = audio.SampleRate;
|
||||||
dto.DvLevel = entity.DvLevel;
|
dto.IsHearingImpaired = audio.IsHearingImpaired.GetValueOrDefault();
|
||||||
dto.RpuPresentFlag = entity.RpuPresentFlag;
|
}
|
||||||
dto.ElPresentFlag = entity.ElPresentFlag;
|
|
||||||
dto.BlPresentFlag = entity.BlPresentFlag;
|
if (entity.VideoDetails is { } video)
|
||||||
dto.DvBlSignalCompatibilityId = entity.DvBlSignalCompatibilityId;
|
{
|
||||||
dto.IsHearingImpaired = entity.IsHearingImpaired.GetValueOrDefault();
|
dto.AspectRatio = video.AspectRatio;
|
||||||
dto.Rotation = entity.Rotation;
|
dto.IsInterlaced = video.IsInterlaced.GetValueOrDefault();
|
||||||
dto.Hdr10PlusPresentFlag = entity.Hdr10PlusPresentFlag;
|
dto.Height = video.Height;
|
||||||
|
dto.Width = video.Width;
|
||||||
|
dto.AverageFrameRate = video.AverageFrameRate;
|
||||||
|
dto.RealFrameRate = video.RealFrameRate;
|
||||||
|
dto.PixelFormat = video.PixelFormat;
|
||||||
|
dto.BitDepth = video.BitDepth;
|
||||||
|
dto.IsAnamorphic = video.IsAnamorphic;
|
||||||
|
dto.RefFrames = video.RefFrames;
|
||||||
|
dto.NalLengthSize = video.NalLengthSize;
|
||||||
|
dto.ColorPrimaries = video.ColorPrimaries;
|
||||||
|
dto.ColorSpace = video.ColorSpace;
|
||||||
|
dto.ColorTransfer = video.ColorTransfer;
|
||||||
|
dto.DvVersionMajor = video.DvVersionMajor;
|
||||||
|
dto.DvVersionMinor = video.DvVersionMinor;
|
||||||
|
dto.DvProfile = video.DvProfile;
|
||||||
|
dto.DvLevel = video.DvLevel;
|
||||||
|
dto.RpuPresentFlag = video.RpuPresentFlag;
|
||||||
|
dto.ElPresentFlag = video.ElPresentFlag;
|
||||||
|
dto.BlPresentFlag = video.BlPresentFlag;
|
||||||
|
dto.DvBlSignalCompatibilityId = video.DvBlSignalCompatibilityId;
|
||||||
|
dto.Rotation = video.Rotation;
|
||||||
|
dto.Hdr10PlusPresentFlag = video.Hdr10PlusPresentFlag;
|
||||||
|
}
|
||||||
|
|
||||||
if (dto.Type is MediaStreamType.Audio or MediaStreamType.Subtitle)
|
if (dto.Type is MediaStreamType.Audio or MediaStreamType.Subtitle)
|
||||||
{
|
{
|
||||||
@@ -202,47 +216,65 @@ public class MediaStreamRepository : IMediaStreamRepository
|
|||||||
|
|
||||||
Codec = dto.Codec,
|
Codec = dto.Codec,
|
||||||
Language = dto.Language,
|
Language = dto.Language,
|
||||||
ChannelLayout = dto.ChannelLayout,
|
|
||||||
Profile = dto.Profile,
|
Profile = dto.Profile,
|
||||||
AspectRatio = dto.AspectRatio,
|
|
||||||
Path = GetPathToSave(dto.Path) ?? dto.Path,
|
Path = GetPathToSave(dto.Path) ?? dto.Path,
|
||||||
IsInterlaced = dto.IsInterlaced,
|
|
||||||
BitRate = dto.BitRate,
|
BitRate = dto.BitRate,
|
||||||
Channels = dto.Channels,
|
|
||||||
SampleRate = dto.SampleRate,
|
|
||||||
IsDefault = dto.IsDefault,
|
IsDefault = dto.IsDefault,
|
||||||
IsForced = dto.IsForced,
|
IsForced = dto.IsForced,
|
||||||
IsExternal = dto.IsExternal,
|
IsExternal = dto.IsExternal,
|
||||||
Height = dto.Height,
|
|
||||||
Width = dto.Width,
|
|
||||||
AverageFrameRate = dto.AverageFrameRate,
|
|
||||||
RealFrameRate = dto.RealFrameRate,
|
|
||||||
Level = dto.Level.HasValue ? (float)dto.Level : null,
|
Level = dto.Level.HasValue ? (float)dto.Level : null,
|
||||||
PixelFormat = dto.PixelFormat,
|
|
||||||
BitDepth = dto.BitDepth,
|
|
||||||
IsAnamorphic = dto.IsAnamorphic,
|
|
||||||
RefFrames = dto.RefFrames,
|
|
||||||
CodecTag = dto.CodecTag,
|
CodecTag = dto.CodecTag,
|
||||||
Comment = dto.Comment,
|
Comment = dto.Comment,
|
||||||
NalLengthSize = dto.NalLengthSize,
|
|
||||||
Title = dto.Title,
|
Title = dto.Title,
|
||||||
TimeBase = dto.TimeBase,
|
TimeBase = dto.TimeBase,
|
||||||
CodecTimeBase = dto.CodecTimeBase,
|
CodecTimeBase = dto.CodecTimeBase,
|
||||||
ColorPrimaries = dto.ColorPrimaries,
|
|
||||||
ColorSpace = dto.ColorSpace,
|
|
||||||
ColorTransfer = dto.ColorTransfer,
|
|
||||||
DvVersionMajor = dto.DvVersionMajor,
|
|
||||||
DvVersionMinor = dto.DvVersionMinor,
|
|
||||||
DvProfile = dto.DvProfile,
|
|
||||||
DvLevel = dto.DvLevel,
|
|
||||||
RpuPresentFlag = dto.RpuPresentFlag,
|
|
||||||
ElPresentFlag = dto.ElPresentFlag,
|
|
||||||
BlPresentFlag = dto.BlPresentFlag,
|
|
||||||
DvBlSignalCompatibilityId = dto.DvBlSignalCompatibilityId,
|
|
||||||
IsHearingImpaired = dto.IsHearingImpaired,
|
|
||||||
Rotation = dto.Rotation,
|
|
||||||
Hdr10PlusPresentFlag = dto.Hdr10PlusPresentFlag,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (dto.Type == MediaStreamType.Audio)
|
||||||
|
{
|
||||||
|
entity.AudioDetails = new AudioStreamDetails
|
||||||
|
{
|
||||||
|
ItemId = itemId,
|
||||||
|
StreamIndex = dto.Index,
|
||||||
|
ChannelLayout = dto.ChannelLayout,
|
||||||
|
Channels = dto.Channels,
|
||||||
|
SampleRate = dto.SampleRate,
|
||||||
|
IsHearingImpaired = dto.IsHearingImpaired,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (dto.Type == MediaStreamType.Video)
|
||||||
|
{
|
||||||
|
entity.VideoDetails = new VideoStreamDetails
|
||||||
|
{
|
||||||
|
ItemId = itemId,
|
||||||
|
StreamIndex = dto.Index,
|
||||||
|
AspectRatio = dto.AspectRatio,
|
||||||
|
IsInterlaced = dto.IsInterlaced,
|
||||||
|
Height = dto.Height,
|
||||||
|
Width = dto.Width,
|
||||||
|
AverageFrameRate = dto.AverageFrameRate,
|
||||||
|
RealFrameRate = dto.RealFrameRate,
|
||||||
|
PixelFormat = dto.PixelFormat,
|
||||||
|
BitDepth = dto.BitDepth,
|
||||||
|
IsAnamorphic = dto.IsAnamorphic,
|
||||||
|
RefFrames = dto.RefFrames,
|
||||||
|
NalLengthSize = dto.NalLengthSize,
|
||||||
|
ColorPrimaries = dto.ColorPrimaries,
|
||||||
|
ColorSpace = dto.ColorSpace,
|
||||||
|
ColorTransfer = dto.ColorTransfer,
|
||||||
|
DvVersionMajor = dto.DvVersionMajor,
|
||||||
|
DvVersionMinor = dto.DvVersionMinor,
|
||||||
|
DvProfile = dto.DvProfile,
|
||||||
|
DvLevel = dto.DvLevel,
|
||||||
|
RpuPresentFlag = dto.RpuPresentFlag,
|
||||||
|
ElPresentFlag = dto.ElPresentFlag,
|
||||||
|
BlPresentFlag = dto.BlPresentFlag,
|
||||||
|
DvBlSignalCompatibilityId = dto.DvBlSignalCompatibilityId,
|
||||||
|
Rotation = dto.Rotation,
|
||||||
|
Hdr10PlusPresentFlag = dto.Hdr10PlusPresentFlag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ public static class OrderMapper
|
|||||||
(ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
(ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
||||||
(ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
(ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
||||||
(ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
|
(ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
|
||||||
(ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
|
(ItemSortBy.SeriesSortName, _) => e => e.TvExtras!.SeriesName,
|
||||||
(ItemSortBy.Album, _) => e => e.Album,
|
(ItemSortBy.Album, _) => e => e.AudioExtras!.Album,
|
||||||
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
|
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
|
||||||
(ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
|
(ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
|
||||||
(ItemSortBy.StartDate, _) => e => e.StartDate,
|
(ItemSortBy.StartDate, _) => e => e.LiveTvExtras!.StartDate,
|
||||||
(ItemSortBy.Name, _) => e => e.CleanName,
|
(ItemSortBy.Name, _) => e => e.CleanName,
|
||||||
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
|
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
|
||||||
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
|
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
|
||||||
@@ -60,10 +60,10 @@ public static class OrderMapper
|
|||||||
(ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
|
(ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
|
||||||
(ItemSortBy.SeriesDatePlayed, not null) => e =>
|
(ItemSortBy.SeriesDatePlayed, not null) => e =>
|
||||||
jellyfinDbContext.BaseItems
|
jellyfinDbContext.BaseItems
|
||||||
.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
|
.Where(w => w.TvExtras!.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
|
||||||
.Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
|
.Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
|
||||||
.Max(f => f),
|
.Max(f => f),
|
||||||
(ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
|
(ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.TvExtras!.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
|
||||||
.Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
|
.Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
|
||||||
.Max(f => f),
|
.Max(f => f),
|
||||||
// ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
|
// ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
|
||||||
|
|||||||
@@ -123,77 +123,81 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
|||||||
var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray();
|
var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray();
|
||||||
|
|
||||||
await using var context = _dbProvider.CreateDbContext();
|
await using var context = _dbProvider.CreateDbContext();
|
||||||
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var existingPersons = await context.Peoples
|
// Use the execution strategy to handle transactional consistency and retries
|
||||||
.Select(e => new
|
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
||||||
{
|
var executionStrategy = context.Database.CreateExecutionStrategy();
|
||||||
item = e,
|
await executionStrategy.ExecuteAsync(async () =>
|
||||||
SelectionKey = e.Name + "-" + e.PersonType
|
|
||||||
})
|
|
||||||
.Where(p => personKeys.Contains(p.SelectionKey))
|
|
||||||
.Select(f => f.item)
|
|
||||||
.ToArrayAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
var toAdd = people
|
|
||||||
.Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist)
|
|
||||||
.Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString()))
|
|
||||||
.Select(Map);
|
|
||||||
|
|
||||||
await context.Peoples.AddRangeAsync(toAdd, cancellationToken).ConfigureAwait(false);
|
|
||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var personsEntities = toAdd.Concat(existingPersons).ToArray();
|
|
||||||
|
|
||||||
var existingMaps = await context.PeopleBaseItemMap
|
|
||||||
.Include(e => e.People)
|
|
||||||
.Where(e => e.ItemId == itemId)
|
|
||||||
.ToListAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
var listOrder = 0;
|
|
||||||
|
|
||||||
foreach (var person in people)
|
|
||||||
{
|
{
|
||||||
if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist)
|
var existingPersons = await context.Peoples
|
||||||
|
.Select(e => new
|
||||||
|
{
|
||||||
|
item = e,
|
||||||
|
SelectionKey = e.Name + "-" + e.PersonType
|
||||||
|
})
|
||||||
|
.Where(p => personKeys.Contains(p.SelectionKey))
|
||||||
|
.Select(f => f.item)
|
||||||
|
.ToArrayAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var toAdd = people
|
||||||
|
.Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist)
|
||||||
|
.Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString()))
|
||||||
|
.Select(Map);
|
||||||
|
|
||||||
|
await context.Peoples.AddRangeAsync(toAdd, cancellationToken).ConfigureAwait(false);
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var personsEntities = toAdd.Concat(existingPersons).ToArray();
|
||||||
|
|
||||||
|
var existingMaps = await context.PeopleBaseItemMap
|
||||||
|
.Include(e => e.People)
|
||||||
|
.Where(e => e.ItemId == itemId)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var listOrder = 0;
|
||||||
|
|
||||||
|
foreach (var person in people)
|
||||||
{
|
{
|
||||||
continue;
|
if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
await context.PeopleBaseItemMap.AddAsync(
|
||||||
|
new PeopleBaseItemMap()
|
||||||
|
{
|
||||||
|
Item = null!,
|
||||||
|
ItemId = itemId,
|
||||||
|
People = null!,
|
||||||
|
PeopleId = entityPerson.Id,
|
||||||
|
ListOrder = listOrder,
|
||||||
|
SortOrder = person.SortOrder,
|
||||||
|
Role = person.Role
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Update the order for existing mappings
|
||||||
|
existingMap.ListOrder = listOrder;
|
||||||
|
existingMap.SortOrder = person.SortOrder;
|
||||||
|
// person mapping already exists so remove from list
|
||||||
|
existingMaps.Remove(existingMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
listOrder++;
|
||||||
}
|
}
|
||||||
|
|
||||||
var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString());
|
context.PeopleBaseItemMap.RemoveRange(existingMaps);
|
||||||
var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
|
|
||||||
if (existingMap is null)
|
|
||||||
{
|
|
||||||
await context.PeopleBaseItemMap.AddAsync(
|
|
||||||
new PeopleBaseItemMap()
|
|
||||||
{
|
|
||||||
Item = null!,
|
|
||||||
ItemId = itemId,
|
|
||||||
People = null!,
|
|
||||||
PeopleId = entityPerson.Id,
|
|
||||||
ListOrder = listOrder,
|
|
||||||
SortOrder = person.SortOrder,
|
|
||||||
Role = person.Role
|
|
||||||
},
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Update the order for existing mappings
|
|
||||||
existingMap.ListOrder = listOrder;
|
|
||||||
existingMap.SortOrder = person.SortOrder;
|
|
||||||
// person mapping already exists so remove from list
|
|
||||||
existingMaps.Remove(existingMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
listOrder++;
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
context.PeopleBaseItemMap.RemoveRange(existingMaps);
|
|
||||||
|
|
||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private PersonInfo Map(People people)
|
private PersonInfo Map(People people)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517</NoWarn>
|
||||||
<TargetFramework>net11.0</TargetFramework>
|
<TargetFramework>net11.0</TargetFramework>
|
||||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AsyncKeyedLock" />
|
<PackageReference Include="AsyncKeyedLock" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
|
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// <copyright file="MigrationRecord.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Migrations;
|
||||||
|
|
||||||
|
/// <summary>Represents one row of the EF migrations history table.</summary>
|
||||||
|
public sealed class MigrationRecord
|
||||||
|
{
|
||||||
|
/// <summary>Gets or sets the migration identifier.</summary>
|
||||||
|
public string MigrationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the product version that applied this migration.</summary>
|
||||||
|
public string ProductVersion { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-SQL tracker for the EF migrations history table.
|
||||||
|
/// Replaces the EF-internal IHistoryRepository / SnakeCaseHistoryRepository.
|
||||||
|
/// </summary>
|
||||||
|
public static class MigrationTracker
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Entry name used when writing/reading migration history from backup archives.
|
||||||
|
/// Kept as "HistoryRow" for backward compatibility with existing backup files.
|
||||||
|
/// </summary>
|
||||||
|
public const string BackupEntryName = "HistoryRow";
|
||||||
|
|
||||||
|
private const string CreateTableSql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS public."__EFMigrationsHistory" (
|
||||||
|
migration_id character varying(150) NOT NULL,
|
||||||
|
product_version character varying(32) NOT NULL,
|
||||||
|
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY (migration_id)
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <summary>Ensures the migrations history table exists.</summary>
|
||||||
|
/// <param name="dbContext">The database context.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the operation.</returns>
|
||||||
|
public static async Task EnsureTableExistsAsync(JellyfinDbContext dbContext, CancellationToken ct = default)
|
||||||
|
=> await dbContext.Database.ExecuteSqlRawAsync(CreateTableSql, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
/// <summary>Returns the migration_id of every applied migration.</summary>
|
||||||
|
/// <param name="dbContext">The database context.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>A read-only list of applied migration identifiers.</returns>
|
||||||
|
public static async Task<IReadOnlyList<string>> GetAppliedMigrationIdsAsync(JellyfinDbContext dbContext, CancellationToken ct = default)
|
||||||
|
=> await dbContext.Database
|
||||||
|
.SqlQueryRaw<string>("""SELECT migration_id AS "Value" FROM public."__EFMigrationsHistory" """)
|
||||||
|
.ToListAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
/// <summary>Returns all applied migration rows.</summary>
|
||||||
|
/// <param name="dbContext">The database context.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>A read-only list of <see cref="MigrationRecord"/> instances.</returns>
|
||||||
|
public static async Task<IReadOnlyList<MigrationRecord>> GetAppliedMigrationsAsync(JellyfinDbContext dbContext, CancellationToken ct = default)
|
||||||
|
=> await dbContext.Database
|
||||||
|
.SqlQueryRaw<MigrationRecord>("""
|
||||||
|
SELECT migration_id AS "MigrationId", product_version AS "ProductVersion"
|
||||||
|
FROM public."__EFMigrationsHistory"
|
||||||
|
""")
|
||||||
|
.ToListAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
/// <summary>Inserts a migration row, ignoring conflicts.</summary>
|
||||||
|
/// <param name="dbContext">The database context.</param>
|
||||||
|
/// <param name="migrationId">The migration identifier.</param>
|
||||||
|
/// <param name="productVersion">The product version.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the operation.</returns>
|
||||||
|
public static async Task InsertAsync(JellyfinDbContext dbContext, string migrationId, string productVersion, CancellationToken ct = default)
|
||||||
|
=> await dbContext.Database.ExecuteSqlAsync(
|
||||||
|
$"""
|
||||||
|
INSERT INTO public."__EFMigrationsHistory" (migration_id, product_version)
|
||||||
|
VALUES ({migrationId}, {productVersion}) ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
/// <summary>Deletes a migration row by id.</summary>
|
||||||
|
/// <param name="dbContext">The database context.</param>
|
||||||
|
/// <param name="migrationId">The migration identifier to delete.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the operation.</returns>
|
||||||
|
public static async Task DeleteAsync(JellyfinDbContext dbContext, string migrationId, CancellationToken ct = default)
|
||||||
|
=> await dbContext.Database.ExecuteSqlAsync(
|
||||||
|
$"""DELETE FROM public."__EFMigrationsHistory" WHERE migration_id = {migrationId}""",
|
||||||
|
ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.ScheduledTasks
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Class PostgresPeriodicTuner.
|
||||||
|
/// </summary>
|
||||||
|
public class PostgresPeriodicTuner : IScheduledTask, IConfigurableScheduledTask
|
||||||
|
{
|
||||||
|
private readonly ILogger<PostgresPeriodicTuner> _logger;
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PostgresPeriodicTuner"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="dbContextFactory">The database context factory.</param>
|
||||||
|
public PostgresPeriodicTuner(ILogger<PostgresPeriodicTuner> logger, IDbContextFactory<JellyfinDbContext> dbContextFactory)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "PostgreSQL Periodic Tuner";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Key => "PostgresPeriodicTuner";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Description => "Periodically checks for dead tuples and runs VACUUM ANALYZE on tables that need it.";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Category => "Database";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsHidden => false;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsEnabled => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsLogged => true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the task.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="progress">The progress.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("PostgreSQL Periodic Tuner task started.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
const string DeadTupleQuery = @"
|
||||||
|
SELECT
|
||||||
|
relname AS TableName,
|
||||||
|
n_dead_tup AS DeadTuples
|
||||||
|
FROM
|
||||||
|
pg_stat_user_tables
|
||||||
|
WHERE
|
||||||
|
n_dead_tup > 1000 -- Example threshold: more than 1000 dead tuples
|
||||||
|
ORDER BY
|
||||||
|
n_dead_tup DESC;";
|
||||||
|
|
||||||
|
var tablesToVacuum = (await dbContext.Database.SqlQueryRaw<(string TableName, long DeadTuples)>(DeadTupleQuery).ToListAsync(cancellationToken).ConfigureAwait(false)).ToList();
|
||||||
|
|
||||||
|
if (tablesToVacuum.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No tables require vacuuming at this time.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.Report(10);
|
||||||
|
|
||||||
|
double progressStep = 90.0 / tablesToVacuum.Count;
|
||||||
|
double currentProgress = 10.0;
|
||||||
|
|
||||||
|
foreach (var table in tablesToVacuum)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
_logger.LogInformation("Found {DeadTuples} dead tuples in table {TableName}. Running VACUUM ANALYZE.", table.DeadTuples, table.TableName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await dbContext.Database.ExecuteSqlInterpolatedAsync($"VACUUM ANALYZE \"{table.TableName}\"", cancellationToken).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Successfully vacuumed and analyzed table {TableName}.", table.TableName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error running VACUUM ANALYZE on table {TableName}.", table.TableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentProgress += progressStep;
|
||||||
|
progress.Report(currentProgress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during PostgreSQL Periodic Tuner task.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_logger.LogInformation("PostgreSQL Periodic Tuner task finished.");
|
||||||
|
progress.Report(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the default triggers.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>IEnumerable<TaskTriggerInfo>.</returns>
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new TaskTriggerInfo
|
||||||
|
{
|
||||||
|
Type = TaskTriggerInfoType.IntervalTrigger,
|
||||||
|
IntervalTicks = TimeSpan.FromHours(24).Ticks
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,15 @@ namespace Jellyfin.Server.Implementations.Security
|
|||||||
IHeaderDictionary headers,
|
IHeaderDictionary headers,
|
||||||
IQueryCollection queryString)
|
IQueryCollection queryString)
|
||||||
{
|
{
|
||||||
|
// Token extraction priority (in order):
|
||||||
|
// 1. Authorization header (MediaBrowser Token parameter)
|
||||||
|
// 2. X-Emby-Token header (if legacy auth enabled)
|
||||||
|
// 3. X-MediaBrowser-Token header (if legacy auth enabled)
|
||||||
|
// 4. ApiKey query parameter
|
||||||
|
// 5. api_key query parameter (if legacy auth enabled)
|
||||||
|
// For WebSocket connections, use query parameters:
|
||||||
|
// ws://server:port/path?api_key=YOUR_TOKEN
|
||||||
|
|
||||||
string? deviceId = null;
|
string? deviceId = null;
|
||||||
string? deviceName = null;
|
string? deviceName = null;
|
||||||
string? client = null;
|
string? client = null;
|
||||||
@@ -140,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Security
|
|||||||
return authInfo;
|
return authInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenPreview = token?.Length > 8 ? token.Substring(0, 8) + "..." : token ?? "null";
|
var tokenPreview = token?.Length > 8 ? string.Concat(token.AsSpan(0, 8), "...") : token ?? "null";
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Validating authentication token. Client: {Client}, Device: {Device}, DeviceId: {DeviceId}, Token: {Token}",
|
"Validating authentication token. Client: {Client}, Device: {Device}, DeviceId: {DeviceId}, Token: {Token}",
|
||||||
client ?? "unknown",
|
client ?? "unknown",
|
||||||
@@ -246,7 +255,7 @@ namespace Jellyfin.Server.Implementations.Security
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var tokenPreview2 = token?.Length > 8 ? token.Substring(0, 8) + "..." : token ?? "null";
|
var tokenPreview2 = token?.Length > 8 ? string.Concat(token.AsSpan(0, 8), "...") : token ?? "null";
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Token validation failed: Token not found in devices or API keys. Token: {Token}",
|
"Token validation failed: Token not found in devices or API keys. Token: {Token}",
|
||||||
tokenPreview2);
|
tokenPreview2);
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ namespace Jellyfin.Server.Extensions
|
|||||||
using Jellyfin.Database.Implementations.Enums;
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
using Jellyfin.Extensions.Json;
|
using Jellyfin.Extensions.Json;
|
||||||
using Jellyfin.Server.Configuration;
|
using Jellyfin.Server.Configuration;
|
||||||
using Jellyfin.Server.Filters;
|
|
||||||
using MediaBrowser.Common.Api;
|
using MediaBrowser.Common.Api;
|
||||||
using MediaBrowser.Common.Net;
|
using MediaBrowser.Common.Net;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
@@ -43,7 +42,6 @@ namespace Jellyfin.Server.Extensions
|
|||||||
using Microsoft.OpenApi.Any;
|
using Microsoft.OpenApi.Any;
|
||||||
using Microsoft.OpenApi.Interfaces;
|
using Microsoft.OpenApi.Interfaces;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
using Swashbuckle.AspNetCore.Swagger;
|
|
||||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
|
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
|
||||||
|
|
||||||
@@ -254,20 +252,7 @@ namespace Jellyfin.Server.Extensions
|
|||||||
// Allow parameters to properly be nullable.
|
// Allow parameters to properly be nullable.
|
||||||
c.UseAllOfToExtendReferenceSchemas();
|
c.UseAllOfToExtendReferenceSchemas();
|
||||||
c.SupportNonNullableReferenceTypes();
|
c.SupportNonNullableReferenceTypes();
|
||||||
|
});
|
||||||
// TODO - remove when all types are supported in System.Text.Json
|
|
||||||
c.AddSwaggerTypeMappings();
|
|
||||||
|
|
||||||
c.SchemaFilter<IgnoreEnumSchemaFilter>();
|
|
||||||
c.SchemaFilter<FlagsEnumSchemaFilter>();
|
|
||||||
c.OperationFilter<RetryOnTemporarilyUnavailableFilter>();
|
|
||||||
c.OperationFilter<SecurityRequirementsOperationFilter>();
|
|
||||||
c.OperationFilter<FileResponseFilter>();
|
|
||||||
c.OperationFilter<FileRequestFilter>();
|
|
||||||
c.OperationFilter<ParameterObsoleteFilter>();
|
|
||||||
c.DocumentFilter<AdditionalModelFilter>();
|
|
||||||
})
|
|
||||||
.Replace(ServiceDescriptor.Transient<ISwaggerProvider, CachingOpenApiProvider>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddPolicy(this AuthorizationOptions authorizationOptions, string policyName, IAuthorizationRequirement authorizationRequirement)
|
private static void AddPolicy(this AuthorizationOptions authorizationOptions, string policyName, IAuthorizationRequirement authorizationRequirement)
|
||||||
@@ -327,40 +312,5 @@ namespace Jellyfin.Server.Extensions
|
|||||||
options.KnownIPNetworks.Add(new System.Net.IPNetwork(addr, prefixLength));
|
options.KnownIPNetworks.Add(new System.Net.IPNetwork(addr, prefixLength));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddSwaggerTypeMappings(this SwaggerGenOptions options)
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
* TODO remove when System.Text.Json properly supports non-string keys.
|
|
||||||
* Used in BaseItemDto.ImageBlurHashes
|
|
||||||
*/
|
|
||||||
options.MapType<Dictionary<ImageType, string>>(() =>
|
|
||||||
new OpenApiSchema
|
|
||||||
{
|
|
||||||
Type = "object",
|
|
||||||
AdditionalProperties = new OpenApiSchema
|
|
||||||
{
|
|
||||||
Type = "string"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Support dictionary with nullable string value.
|
|
||||||
options.MapType<Dictionary<string, string?>>(() =>
|
|
||||||
new OpenApiSchema
|
|
||||||
{
|
|
||||||
Type = "object",
|
|
||||||
AdditionalProperties = new OpenApiSchema
|
|
||||||
{
|
|
||||||
Type = "string",
|
|
||||||
Nullable = true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Swashbuckle doesn't use JsonOptions to describe responses, so we need to manually describe it.
|
|
||||||
options.MapType<Version>(() => new OpenApiSchema
|
|
||||||
{
|
|
||||||
Type = "string"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public static class StartupHelpers
|
|||||||
{
|
{
|
||||||
private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" };
|
private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" };
|
||||||
|
|
||||||
|
private static readonly System.Text.Json.JsonSerializerOptions _jsonSerializerOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Logs relevant environment variables and information about the host.
|
/// Logs relevant environment variables and information about the host.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -235,10 +240,7 @@ public static class StartupHelpers
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(defaultConfig, new System.Text.Json.JsonSerializerOptions
|
var json = System.Text.Json.JsonSerializer.Serialize(defaultConfig, _jsonSerializerOptions);
|
||||||
{
|
|
||||||
WriteIndented = true
|
|
||||||
});
|
|
||||||
|
|
||||||
File.WriteAllText(configPath, json);
|
File.WriteAllText(configPath, json);
|
||||||
Console.WriteLine($"Created default startup configuration at: {configPath}");
|
Console.WriteLine($"Created default startup configuration at: {configPath}");
|
||||||
@@ -368,7 +370,7 @@ public static class StartupHelpers
|
|||||||
|
|
||||||
// If $XDG_CACHE_HOME is either not set or a relative path,
|
// If $XDG_CACHE_HOME is either not set or a relative path,
|
||||||
// a default equal to $HOME/.cache should be used.
|
// a default equal to $HOME/.cache should be used.
|
||||||
if (cacheHome is null || !cacheHome.StartsWith("/", StringComparison.Ordinal))
|
if (cacheHome is null || !cacheHome.StartsWith('/', StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
cacheHome = Path.Join(
|
cacheHome = Path.Join(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify),
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify),
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CS1061;CS0103</NoWarn>
|
||||||
<ProjectGuid>{07E39F42-A2C6-4B32-AF8C-725F957A73FF}</ProjectGuid>
|
<ProjectGuid>{07E39F42-A2C6-4B32-AF8C-725F957A73FF}</ProjectGuid>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CS1061;CS0103</NoWarn>
|
||||||
<AssemblyName>jellyfin</AssemblyName>
|
<AssemblyName>jellyfin</AssemblyName>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net11.0</TargetFramework>
|
<TargetFramework>net11.0</TargetFramework>
|
||||||
@@ -14,8 +16,15 @@
|
|||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<ApplicationIcon>Jellyfin.Server.ico</ApplicationIcon>
|
<ApplicationIcon>Jellyfin.Server.ico</ApplicationIcon>
|
||||||
<IsPackable>true</IsPackable>
|
<IsPackable>true</IsPackable>
|
||||||
|
<EnableDefaultContentItems>false</EnableDefaultContentItems>
|
||||||
|
<!-- Enable assembly binding redirects in runtimeconfig.json -->
|
||||||
|
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="Filters/**/*.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\SharedVersion.cs" />
|
<Compile Include="..\SharedVersion.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -39,6 +48,11 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
<PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
|
<PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
|
||||||
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
|
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
|
||||||
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
|
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
|
||||||
@@ -60,7 +74,11 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CommandLineParser" />
|
<PackageReference Include="CommandLineParser" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
|
||||||
|
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
||||||
<PackageReference Include="Morestachio" />
|
<PackageReference Include="Morestachio" />
|
||||||
<PackageReference Include="prometheus-net" />
|
<PackageReference Include="prometheus-net" />
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" />
|
<PackageReference Include="prometheus-net.AspNetCore" />
|
||||||
@@ -74,6 +92,8 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.Graylog" />
|
<PackageReference Include="Serilog.Sinks.Graylog" />
|
||||||
<!-- SQLite package for migration routines only (SQLite -> PostgreSQL) -->
|
<!-- SQLite package for migration routines only (SQLite -> PostgreSQL) -->
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyModel" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -88,36 +108,37 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="wwwroot\api-docs\redoc\custom.css">
|
<!-- Web content and static files -->
|
||||||
|
<None Include="wwwroot\**\*.*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<Link>wwwroot\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
</None>
|
</None>
|
||||||
<None Update="wwwroot\api-docs\swagger\custom.css">
|
<None Include="wwwroot\*.*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<Link>wwwroot\%(Filename)%(Extension)</Link>
|
||||||
</None>
|
</None>
|
||||||
<None Update="wwwroot\api-docs\jellyfin.svg">
|
<!-- ServerSetupApp files -->
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<None Include="ServerSetupApp\index.mstemplate.html">
|
||||||
</None>
|
|
||||||
<None Update="ServerSetupApp/index.mstemplate.html">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- SQL initialization scripts for PostgreSQL -->
|
<!-- SQL initialization scripts for PostgreSQL -->
|
||||||
<None Include="sql\**\*.sql">
|
<None Include="..\scripts\sql\**\*.sql">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
<Pack>false</Pack>
|
<Link>sql\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
</None>
|
||||||
|
<!-- Utility scripts (excluding SQL files) -->
|
||||||
|
<None Include="..\scripts\**\*" Exclude="..\scripts\sql\**\*.sql">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<Link>scripts\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Post-build event to ensure SQL files are copied -->
|
|
||||||
<Target Name="CopySQLFiles" AfterTargets="Build">
|
|
||||||
<ItemGroup>
|
|
||||||
<SQLFiles Include="$(ProjectDir)sql\**\*.sql" />
|
|
||||||
</ItemGroup>
|
|
||||||
<Copy SourceFiles="@(SQLFiles)" DestinationFolder="$(OutDir)sql\%(RecursiveDir)" SkipUnchangedFiles="true" />
|
|
||||||
<Message Importance="high" Text="Copied SQL files to output directory" />
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CS1061;CS0103</NoWarn>
|
||||||
|
<ProjectGuid>{07E39F42-A2C6-4B32-AF8C-725F957A73FF}</ProjectGuid>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);CA1024;CA1031;CA1032;CA1034;CA1054;CA1055;CA1056;CA1062;CA1308;CA1303;CA1305;CA1508;CA1722;CA1724;CA1805;CA1813;CA1815;CA1816;CA1819;CA1821;CA1822;CA1823;CA1824;CA1825;CA1826;CA1830;CA1836;CA1839;CA1840;CA1841;CA1859;CA1861;CA1873;CA1911;CA2000;CA2007;CA2016;CA2025;CA2026;CA2100;CA2101;CA2102;CA2201;CA2202;CA2207;CA2208;CA2219;CA2220;CA2222;CA2225;CA2226;CA2230;CA2231;CA2234;CA2241;CA2242;CA2243;CA2244;CA2245;CA2246;CA2247;CA2248;CA2249;CA2250;CA2253;CA3001;CA3002;CA3003;CA3004;CA3005;CA3006;CA3007;CA3008;CA3009;CA3010;CA3011;CA3012;CA3061;CA3062;CA5359;CA5360;CA5361;CA5362;CA5363;CA5364;CA5365;CA5366;CA5367;CA5368;CA5369;CA5370;CA5371;CA5372;CA5373;CA5374;CA5375;CA5376;CA5377;CA5378;CA5379;CA5380;CA5381;CA5382;CA5383;CA5384;CA5385;CA5386;CA5387;CA5388;CA5389;CA5390;CA5391;CA5392;CA5393;CA5394;CA5395;CA5396;CA5397;CA5398;CA5399;CA5400;CA5401;CA5402;CA5403;NU1903;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CS1061;CS0103</NoWarn>
|
||||||
|
<AssemblyName>jellyfin</AssemblyName>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net11.0</TargetFramework>
|
||||||
|
<ServerGarbageCollection>false</ServerGarbageCollection>
|
||||||
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<ApplicationIcon>Jellyfin.Server.ico</ApplicationIcon>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<EnableDefaultContentItems>false</EnableDefaultContentItems>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="Filters/**/*.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\SharedVersion.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="Jellyfin.Server.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Resources/Configuration/*" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Code Analyzers -->
|
||||||
|
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||||
|
<PackageReference Include="IDisposableAnalyzers">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Jellyfin.Sdk" />
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Remove="startup.json.example" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="startup.json.linux">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</None>
|
||||||
|
<None Include="startup.json.windows">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CommandLineParser" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyModel" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
|
||||||
|
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
||||||
|
<PackageReference Include="Morestachio" />
|
||||||
|
<PackageReference Include="prometheus-net" />
|
||||||
|
<PackageReference Include="prometheus-net.AspNetCore" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" />
|
||||||
|
<PackageReference Include="Serilog.Enrichers.Thread" />
|
||||||
|
<PackageReference Include="Serilog.Expressions" />
|
||||||
|
<PackageReference Include="Serilog.Settings.Configuration" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Async" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Graylog" />
|
||||||
|
<!-- SQLite package for migration routines only (SQLite -> PostgreSQL) -->
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\src\Jellyfin.Drawing\Jellyfin.Drawing.csproj" />
|
||||||
|
<ProjectReference Include="..\Emby.Server.Implementations\Emby.Server.Implementations.csproj" />
|
||||||
|
<ProjectReference Include="..\src\Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj" />
|
||||||
|
<ProjectReference Include="..\src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj" />
|
||||||
|
<ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" />
|
||||||
|
<ProjectReference Include="..\src\Jellyfin.MediaEncoding.Hls\Jellyfin.MediaEncoding.Hls.csproj" />
|
||||||
|
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
|
||||||
|
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.Postgres\Jellyfin.Database.Providers.Postgres.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Web content and static files -->
|
||||||
|
<None Include="wwwroot\**\*.*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<Link>wwwroot\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
</None>
|
||||||
|
<None Include="wwwroot\*.*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<Link>wwwroot\%(Filename)%(Extension)</Link>
|
||||||
|
</None>
|
||||||
|
<!-- ServerSetupApp files -->
|
||||||
|
<None Include="ServerSetupApp\index.mstemplate.html">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</None>
|
||||||
|
<!-- Runtime configuration for binding redirects -->
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- SQL initialization scripts for PostgreSQL -->
|
||||||
|
<None Include="..\scripts\sql\**\*.sql">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<Link>sql\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
</None>
|
||||||
|
<!-- Utility scripts (excluding SQL files) -->
|
||||||
|
<None Include="..\scripts\**\*" Exclude="..\scripts\sql\**\*.sql">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<Link>scripts\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
// </copyright>
|
// </copyright>
|
||||||
|
|
||||||
|
#pragma warning disable CS8602 // Dereference of a possibly null reference
|
||||||
|
|
||||||
namespace Jellyfin.Server.Migrations;
|
namespace Jellyfin.Server.Migrations;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
@@ -14,6 +16,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Emby.Server.Implementations.Serialization;
|
using Emby.Server.Implementations.Serialization;
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Server.Implementations.Migrations;
|
||||||
using Jellyfin.Server.Implementations.SystemBackupService;
|
using Jellyfin.Server.Implementations.SystemBackupService;
|
||||||
using Jellyfin.Server.Migrations.Stages;
|
using Jellyfin.Server.Migrations.Stages;
|
||||||
using Jellyfin.Server.ServerSetupApp;
|
using Jellyfin.Server.ServerSetupApp;
|
||||||
@@ -22,7 +25,6 @@ using MediaBrowser.Controller.SystemBackupService;
|
|||||||
using MediaBrowser.Model.Configuration;
|
using MediaBrowser.Model.Configuration;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -97,7 +99,7 @@ internal class JellyfinMigrationService
|
|||||||
|
|
||||||
public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
|
public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
|
||||||
{
|
{
|
||||||
var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup");
|
var logger = _startupLogger.Attach(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup");
|
||||||
logger.LogInformation("Initialise Migration service.");
|
logger.LogInformation("Initialise Migration service.");
|
||||||
var xmlSerializer = new MyXmlSerializer();
|
var xmlSerializer = new MyXmlSerializer();
|
||||||
var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
|
var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
|
||||||
@@ -111,25 +113,25 @@ internal class JellyfinMigrationService
|
|||||||
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
// DISABLED for .NET 11 preview: Database creation handled by PostgresDatabaseProvider using SQL scripts
|
||||||
|
/*
|
||||||
var databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator
|
var databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator
|
||||||
?? throw new InvalidOperationException("Jellyfin does only support relational databases.");
|
?? throw new InvalidOperationException("Jellyfin does only support relational databases.");
|
||||||
if (!await databaseCreator.ExistsAsync().ConfigureAwait(false))
|
if (!await databaseCreator.ExistsAsync().ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await databaseCreator.CreateAsync().ConfigureAwait(false);
|
await databaseCreator.CreateAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
|
||||||
|
var appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false);
|
||||||
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
|
var migrationsToSeed = flatApplyMigrations
|
||||||
var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
|
||||||
var startupScripts = flatApplyMigrations
|
|
||||||
.Where(e => !appliedMigrations.Any(f => f != e.BuildCodeMigrationId()))
|
.Where(e => !appliedMigrations.Any(f => f != e.BuildCodeMigrationId()))
|
||||||
.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))))
|
|
||||||
.ToArray();
|
.ToArray();
|
||||||
foreach (var item in startupScripts)
|
foreach (var item in migrationsToSeed)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Seed migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
|
logger.LogInformation("Seed migration {Key}-{Name}.", item.Metadata.Key, item.Metadata.Name);
|
||||||
await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
|
await MigrationTracker.InsertAsync(dbContext, item.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +152,8 @@ internal class JellyfinMigrationService
|
|||||||
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
|
||||||
var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
var appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false);
|
||||||
var lastOldAppliedMigration = Migrations
|
var lastOldAppliedMigration = Migrations
|
||||||
.SelectMany(e => e.Where(e => e.Metadata.Key is not null)) // only consider migrations that have the key set as its the reference marker for legacy migrations.
|
.SelectMany(e => e.Where(e => e.Metadata.Key is not null)) // only consider migrations that have the key set as its the reference marker for legacy migrations.
|
||||||
.Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value)))
|
.Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value)))
|
||||||
@@ -168,11 +170,10 @@ internal class JellyfinMigrationService
|
|||||||
];
|
];
|
||||||
// those are all migrations that had to run in the old migration system, even if not noted in the migration.xml file.
|
// those are all migrations that had to run in the old migration system, even if not noted in the migration.xml file.
|
||||||
|
|
||||||
var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))));
|
foreach (var item in oldMigrations)
|
||||||
foreach (var item in startupScripts)
|
|
||||||
{
|
{
|
||||||
logger.LogInformation("Migrate migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
|
logger.LogInformation("Migrate migration {Key}-{Name}.", item.Metadata.Key, item.Metadata.Name);
|
||||||
await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
|
await MigrationTracker.InsertAsync(dbContext, item.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("Rename old migration.xml to migration.xml.backup");
|
logger.LogInformation("Rename old migration.xml to migration.xml.backup");
|
||||||
@@ -190,7 +191,7 @@ internal class JellyfinMigrationService
|
|||||||
|
|
||||||
public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
|
public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
|
||||||
{
|
{
|
||||||
var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}.");
|
var logger = _startupLogger.Attach(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}.");
|
||||||
|
|
||||||
// Ensure database exists before attempting migrations (PostgreSQL-specific)
|
// Ensure database exists before attempting migrations (PostgreSQL-specific)
|
||||||
if (stage == JellyfinMigrationStageTypes.CoreInitialisation && _jellyfinDatabaseProvider is Jellyfin.Database.Providers.Postgres.PostgresDatabaseProvider postgresProvider)
|
if (stage == JellyfinMigrationStageTypes.CoreInitialisation && _jellyfinDatabaseProvider is Jellyfin.Database.Providers.Postgres.PostgresDatabaseProvider postgresProvider)
|
||||||
@@ -235,35 +236,24 @@ internal class JellyfinMigrationService
|
|||||||
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
// Ensure the migration history table exists before querying it
|
// Ensure database exists before querying migration history
|
||||||
|
// Note: This does NOT run migrations, it only ensures the database itself exists
|
||||||
var databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator;
|
var databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator;
|
||||||
if (databaseCreator is not null && !await databaseCreator.ExistsAsync().ConfigureAwait(false))
|
if (databaseCreator is not null && !await databaseCreator.ExistsAsync().ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
logger.LogInformation("Database does not exist, creating...");
|
logger.LogInformation("Database does not exist, creating empty database...");
|
||||||
await databaseCreator.CreateAsync().ConfigureAwait(false);
|
await databaseCreator.CreateAsync().ConfigureAwait(false);
|
||||||
|
logger.LogInformation("Empty database created successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
|
||||||
|
var appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false);
|
||||||
// Explicitly ensure the __EFMigrationsHistory table exists
|
|
||||||
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
|
|
||||||
var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
|
||||||
var pendingCodeMigrations = migrationStage
|
var pendingCodeMigrations = migrationStage
|
||||||
.Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
|
.Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId()))
|
||||||
.Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext)))
|
.Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
(string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = [];
|
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations];
|
||||||
if (stage is JellyfinMigrationStageTypes.CoreInitialisation)
|
|
||||||
{
|
|
||||||
pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key))
|
|
||||||
.Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext)))
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
|
|
||||||
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage);
|
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage);
|
||||||
var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
|
var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
|
||||||
|
|
||||||
@@ -384,16 +374,15 @@ internal class JellyfinMigrationService
|
|||||||
{
|
{
|
||||||
logger.LogInformation("Prepare system for possible migrations");
|
logger.LogInformation("Prepare system for possible migrations");
|
||||||
JellyfinMigrationBackupAttribute backupInstruction;
|
JellyfinMigrationBackupAttribute backupInstruction;
|
||||||
IReadOnlyList<HistoryRow> appliedMigrations;
|
IReadOnlyList<string> appliedMigrations;
|
||||||
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
|
||||||
var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
|
appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false);
|
||||||
appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
|
||||||
backupInstruction = new JellyfinMigrationBackupAttribute()
|
backupInstruction = new JellyfinMigrationBackupAttribute()
|
||||||
{
|
{
|
||||||
JellyfinDb = migrationsAssembly.Migrations.Any(f => appliedMigrations.All(e => e.MigrationId != f.Key))
|
JellyfinDb = false // Schema migrations are disabled; no EF schema migrations are ever pending
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +391,7 @@ internal class JellyfinMigrationService
|
|||||||
bool isSqliteProvider = false;
|
bool isSqliteProvider = false;
|
||||||
|
|
||||||
backupInstruction = Migrations.SelectMany(e => e)
|
backupInstruction = Migrations.SelectMany(e => e)
|
||||||
.Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
|
.Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId()))
|
||||||
.Where(e => isSqliteProvider || !e.Metadata.RequiresSqlite) // Skip SQLite migrations for non-SQLite providers
|
.Where(e => isSqliteProvider || !e.Metadata.RequiresSqlite) // Skip SQLite migrations for non-SQLite providers
|
||||||
.Select(e => e.BackupRequirements)
|
.Select(e => e.BackupRequirements)
|
||||||
.Where(e => e is not null)
|
.Where(e => e is not null)
|
||||||
@@ -494,27 +483,8 @@ internal class JellyfinMigrationService
|
|||||||
{
|
{
|
||||||
await _codeMigration.Perform(_serviceProvider, logger, CancellationToken.None).ConfigureAwait(false);
|
await _codeMigration.Perform(_serviceProvider, logger, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
var historyRepository = _dbContext.GetService<IHistoryRepository>();
|
await MigrationTracker.InsertAsync(_dbContext, _codeMigration.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false);
|
||||||
var createScript = historyRepository.GetInsertScript(new HistoryRow(_codeMigration.BuildCodeMigrationId(), GetJellyfinVersion()));
|
|
||||||
await _dbContext.Database.ExecuteSqlRawAsync(createScript).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class InternalDatabaseMigration : IInternalMigration
|
|
||||||
{
|
|
||||||
private readonly JellyfinDbContext _jellyfinDbContext;
|
|
||||||
private KeyValuePair<string, TypeInfo> _databaseMigrationInfo;
|
|
||||||
|
|
||||||
public InternalDatabaseMigration(KeyValuePair<string, TypeInfo> databaseMigrationInfo, JellyfinDbContext jellyfinDbContext)
|
|
||||||
{
|
|
||||||
_databaseMigrationInfo = databaseMigrationInfo;
|
|
||||||
_jellyfinDbContext = jellyfinDbContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task PerformAsync(IStartupLogger logger)
|
|
||||||
{
|
|
||||||
var migrator = _jellyfinDbContext.GetService<IMigrator>();
|
|
||||||
await migrator.MigrateAsync(_databaseMigrationInfo.Key).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class FixDates : IAsyncMigrationRoutine
|
|||||||
IStartupLogger<FixDates> startupLogger,
|
IStartupLogger<FixDates> startupLogger,
|
||||||
IDbContextFactory<JellyfinDbContext> dbProvider)
|
IDbContextFactory<JellyfinDbContext> dbProvider)
|
||||||
{
|
{
|
||||||
_logger = startupLogger.With(logger);
|
_logger = startupLogger.Attach(logger);
|
||||||
_dbProvider = dbProvider;
|
_dbProvider = dbProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,30 +64,36 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
|
|||||||
_logger.LogInformation("Checking {Count} items for importable keyframe data.", records);
|
_logger.LogInformation("Checking {Count} items for importable keyframe data.", records);
|
||||||
|
|
||||||
context.KeyframeData.ExecuteDelete();
|
context.KeyframeData.ExecuteDelete();
|
||||||
using var transaction = context.Database.BeginTransaction();
|
|
||||||
do
|
// NpgsqlRetryingExecutionStrategy requires all operations inside a user-initiated
|
||||||
|
// transaction to be wrapped with CreateExecutionStrategy().Execute(...).
|
||||||
|
context.Database.CreateExecutionStrategy().Execute(() =>
|
||||||
{
|
{
|
||||||
var results = baseQuery.Skip(offset).Take(Limit).Select(b => new Tuple<Guid, string?>(b.Id, b.Path)).ToList();
|
using var transaction = context.Database.BeginTransaction();
|
||||||
foreach (var result in results)
|
do
|
||||||
{
|
{
|
||||||
if (TryGetKeyframeData(result.Item1, result.Item2, out var data))
|
var results = baseQuery.Skip(offset).Take(Limit).Select(b => new Tuple<Guid, string?>(b.Id, b.Path)).ToList();
|
||||||
|
foreach (var result in results)
|
||||||
{
|
{
|
||||||
itemCount++;
|
if (TryGetKeyframeData(result.Item1, result.Item2, out var data))
|
||||||
context.KeyframeData.Add(data);
|
{
|
||||||
|
itemCount++;
|
||||||
|
context.KeyframeData.Add(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
offset += Limit;
|
offset += Limit;
|
||||||
if (offset > records)
|
if (offset > records)
|
||||||
{
|
{
|
||||||
offset = records;
|
offset = records;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Checked: {Count} - Imported: {Items} - Time: {Time}", offset, itemCount, sw.Elapsed);
|
_logger.LogInformation("Checked: {Count} - Imported: {Items} - Time: {Time}", offset, itemCount, sw.Elapsed);
|
||||||
} while (offset < records);
|
} while (offset < records);
|
||||||
|
|
||||||
context.SaveChanges();
|
context.SaveChanges();
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
|
});
|
||||||
|
|
||||||
_logger.LogInformation("Imported keyframes for {Count} items in {Time}", itemCount, sw.Elapsed);
|
_logger.LogInformation("Imported keyframes for {Count} items in {Time}", itemCount, sw.Elapsed);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
// </copyright>
|
// </copyright>
|
||||||
|
|
||||||
#pragma warning disable RS0030 // Do not use banned APIs
|
#pragma warning disable RS0030 // Do not use banned APIs
|
||||||
|
#pragma warning disable CS1061 // Type does not contain definition (legacy schema migration)
|
||||||
|
#pragma warning disable CS0103 // Name does not exist (legacy code)
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
@@ -95,7 +97,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
}
|
}
|
||||||
|
|
||||||
// notify the other migration to just silently abort because the fix has been applied here already.
|
// notify the other migration to just silently abort because the fix has been applied here already.
|
||||||
ReseedFolderFlag.RerunGuardFlag = true;
|
// ReseedFolderFlag.RerunGuardFlag = true;
|
||||||
|
|
||||||
var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>();
|
var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>();
|
||||||
connection.Open();
|
connection.Open();
|
||||||
@@ -596,19 +598,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
/// <returns>MediaStream.</returns>
|
/// <returns>MediaStream.</returns>
|
||||||
private MediaStreamInfo GetMediaStream(SqliteDataReader reader)
|
private MediaStreamInfo GetMediaStream(SqliteDataReader reader)
|
||||||
{
|
{
|
||||||
|
var streamType = Enum.Parse<MediaStreamTypeEntity>(reader.GetString(2));
|
||||||
|
var itemId = reader.GetGuid(0);
|
||||||
|
var streamIndex = reader.GetInt32(1);
|
||||||
|
|
||||||
var item = new MediaStreamInfo
|
var item = new MediaStreamInfo
|
||||||
{
|
{
|
||||||
StreamIndex = reader.GetInt32(1),
|
StreamIndex = streamIndex,
|
||||||
StreamType = Enum.Parse<MediaStreamTypeEntity>(reader.GetString(2)),
|
StreamType = streamType,
|
||||||
Item = null!,
|
Item = null!,
|
||||||
ItemId = reader.GetGuid(0),
|
ItemId = itemId,
|
||||||
AspectRatio = null!,
|
|
||||||
ChannelLayout = null!,
|
|
||||||
Codec = null!,
|
|
||||||
IsInterlaced = false,
|
|
||||||
Language = null!,
|
|
||||||
Path = null!,
|
|
||||||
Profile = null!,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (reader.TryGetString(3, out var codec))
|
if (reader.TryGetString(3, out var codec))
|
||||||
@@ -621,92 +620,30 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
item.Language = language;
|
item.Language = language;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(5, out var channelLayout))
|
|
||||||
{
|
|
||||||
item.ChannelLayout = channelLayout;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetString(6, out var profile))
|
if (reader.TryGetString(6, out var profile))
|
||||||
{
|
{
|
||||||
item.Profile = profile;
|
item.Profile = profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(7, out var aspectRatio))
|
|
||||||
{
|
|
||||||
item.AspectRatio = aspectRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetString(8, out var path))
|
if (reader.TryGetString(8, out var path))
|
||||||
{
|
{
|
||||||
item.Path = path;
|
item.Path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
item.IsInterlaced = reader.GetBoolean(9);
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(10, out var bitrate))
|
if (reader.TryGetInt32(10, out var bitrate))
|
||||||
{
|
{
|
||||||
item.BitRate = bitrate;
|
item.BitRate = bitrate;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetInt32(11, out var channels))
|
|
||||||
{
|
|
||||||
item.Channels = channels;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(12, out var sampleRate))
|
|
||||||
{
|
|
||||||
item.SampleRate = sampleRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.IsDefault = reader.GetBoolean(13);
|
item.IsDefault = reader.GetBoolean(13);
|
||||||
item.IsForced = reader.GetBoolean(14);
|
item.IsForced = reader.GetBoolean(14);
|
||||||
item.IsExternal = reader.GetBoolean(15);
|
item.IsExternal = reader.GetBoolean(15);
|
||||||
|
|
||||||
if (reader.TryGetInt32(16, out var width))
|
|
||||||
{
|
|
||||||
item.Width = width;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(17, out var height))
|
|
||||||
{
|
|
||||||
item.Height = height;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetSingle(18, out var averageFrameRate))
|
|
||||||
{
|
|
||||||
item.AverageFrameRate = averageFrameRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetSingle(19, out var realFrameRate))
|
|
||||||
{
|
|
||||||
item.RealFrameRate = realFrameRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetSingle(20, out var level))
|
if (reader.TryGetSingle(20, out var level))
|
||||||
{
|
{
|
||||||
item.Level = level;
|
item.Level = level;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(21, out var pixelFormat))
|
|
||||||
{
|
|
||||||
item.PixelFormat = pixelFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(22, out var bitDepth))
|
|
||||||
{
|
|
||||||
item.BitDepth = bitDepth;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetBoolean(23, out var isAnamorphic))
|
|
||||||
{
|
|
||||||
item.IsAnamorphic = isAnamorphic;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(24, out var refFrames))
|
|
||||||
{
|
|
||||||
item.RefFrames = refFrames;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetString(25, out var codecTag))
|
if (reader.TryGetString(25, out var codecTag))
|
||||||
{
|
{
|
||||||
item.CodecTag = codecTag;
|
item.CodecTag = codecTag;
|
||||||
@@ -717,11 +654,6 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
item.Comment = comment;
|
item.Comment = comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(27, out var nalLengthSize))
|
|
||||||
{
|
|
||||||
item.NalLengthSize = nalLengthSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetBoolean(28, out var isAVC))
|
if (reader.TryGetBoolean(28, out var isAVC))
|
||||||
{
|
{
|
||||||
item.IsAvc = isAVC;
|
item.IsAvc = isAVC;
|
||||||
@@ -742,68 +674,151 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
item.CodecTimeBase = codecTimeBase;
|
item.CodecTimeBase = codecTimeBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(32, out var colorPrimaries))
|
if (streamType == MediaStreamTypeEntity.Audio)
|
||||||
{
|
{
|
||||||
item.ColorPrimaries = colorPrimaries;
|
var audio = new AudioStreamDetails
|
||||||
}
|
{
|
||||||
|
ItemId = itemId,
|
||||||
|
StreamIndex = streamIndex,
|
||||||
|
};
|
||||||
|
|
||||||
if (reader.TryGetString(33, out var colorSpace))
|
if (reader.TryGetString(5, out var channelLayout))
|
||||||
|
{
|
||||||
|
audio.ChannelLayout = channelLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(11, out var channels))
|
||||||
|
{
|
||||||
|
audio.Channels = channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(12, out var sampleRate))
|
||||||
|
{
|
||||||
|
audio.SampleRate = sampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.IsHearingImpaired = reader.TryGetBoolean(43, out var hearingImpaired) && hearingImpaired;
|
||||||
|
|
||||||
|
item.AudioDetails = audio;
|
||||||
|
}
|
||||||
|
else if (streamType == MediaStreamTypeEntity.Video)
|
||||||
{
|
{
|
||||||
item.ColorSpace = colorSpace;
|
var video = new VideoStreamDetails
|
||||||
|
{
|
||||||
|
ItemId = itemId,
|
||||||
|
StreamIndex = streamIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reader.TryGetString(7, out var aspectRatio))
|
||||||
|
{
|
||||||
|
video.AspectRatio = aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.IsInterlaced = reader.GetBoolean(9);
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(16, out var width))
|
||||||
|
{
|
||||||
|
video.Width = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(17, out var height))
|
||||||
|
{
|
||||||
|
video.Height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetSingle(18, out var averageFrameRate))
|
||||||
|
{
|
||||||
|
video.AverageFrameRate = averageFrameRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetSingle(19, out var realFrameRate))
|
||||||
|
{
|
||||||
|
video.RealFrameRate = realFrameRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetString(21, out var pixelFormat))
|
||||||
|
{
|
||||||
|
video.PixelFormat = pixelFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(22, out var bitDepth))
|
||||||
|
{
|
||||||
|
video.BitDepth = bitDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetBoolean(23, out var isAnamorphic))
|
||||||
|
{
|
||||||
|
video.IsAnamorphic = isAnamorphic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(24, out var refFrames))
|
||||||
|
{
|
||||||
|
video.RefFrames = refFrames;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetString(27, out var nalLengthSize))
|
||||||
|
{
|
||||||
|
video.NalLengthSize = nalLengthSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetString(32, out var colorPrimaries))
|
||||||
|
{
|
||||||
|
video.ColorPrimaries = colorPrimaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetString(33, out var colorSpace))
|
||||||
|
{
|
||||||
|
video.ColorSpace = colorSpace;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetString(34, out var colorTransfer))
|
||||||
|
{
|
||||||
|
video.ColorTransfer = colorTransfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(35, out var dvVersionMajor))
|
||||||
|
{
|
||||||
|
video.DvVersionMajor = dvVersionMajor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(36, out var dvVersionMinor))
|
||||||
|
{
|
||||||
|
video.DvVersionMinor = dvVersionMinor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(37, out var dvProfile))
|
||||||
|
{
|
||||||
|
video.DvProfile = dvProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(38, out var dvLevel))
|
||||||
|
{
|
||||||
|
video.DvLevel = dvLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(39, out var rpuPresentFlag))
|
||||||
|
{
|
||||||
|
video.RpuPresentFlag = rpuPresentFlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(40, out var elPresentFlag))
|
||||||
|
{
|
||||||
|
video.ElPresentFlag = elPresentFlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(41, out var blPresentFlag))
|
||||||
|
{
|
||||||
|
video.BlPresentFlag = blPresentFlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TryGetInt32(42, out var dvBlSignalCompatibilityId))
|
||||||
|
{
|
||||||
|
video.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.VideoDetails = video;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(34, out var colorTransfer))
|
|
||||||
{
|
|
||||||
item.ColorTransfer = colorTransfer;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(35, out var dvVersionMajor))
|
|
||||||
{
|
|
||||||
item.DvVersionMajor = dvVersionMajor;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(36, out var dvVersionMinor))
|
|
||||||
{
|
|
||||||
item.DvVersionMinor = dvVersionMinor;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(37, out var dvProfile))
|
|
||||||
{
|
|
||||||
item.DvProfile = dvProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(38, out var dvLevel))
|
|
||||||
{
|
|
||||||
item.DvLevel = dvLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(39, out var rpuPresentFlag))
|
|
||||||
{
|
|
||||||
item.RpuPresentFlag = rpuPresentFlag;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(40, out var elPresentFlag))
|
|
||||||
{
|
|
||||||
item.ElPresentFlag = elPresentFlag;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(41, out var blPresentFlag))
|
|
||||||
{
|
|
||||||
item.BlPresentFlag = blPresentFlag;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.TryGetInt32(42, out var dvBlSignalCompatibilityId))
|
|
||||||
{
|
|
||||||
item.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.IsHearingImpaired = reader.TryGetBoolean(43, out var result) && result;
|
|
||||||
|
|
||||||
// if (reader.TryGetInt32(44, out var rotation))
|
|
||||||
// {
|
|
||||||
// item.Rotation = rotation;
|
|
||||||
// }
|
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,12 +881,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryReadDateTime(index++, out var startDate))
|
if (reader.TryReadDateTime(index++, out var startDate))
|
||||||
{
|
{
|
||||||
entity.StartDate = startDate;
|
// entity.StartDate = startDate; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryReadDateTime(index++, out var endDate))
|
if (reader.TryReadDateTime(index++, out var endDate))
|
||||||
{
|
{
|
||||||
entity.EndDate = endDate;
|
// entity.EndDate = endDate; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetGuid(index++, out var guid))
|
if (reader.TryGetGuid(index++, out var guid))
|
||||||
@@ -891,7 +906,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var episodeTitle))
|
if (reader.TryGetString(index++, out var episodeTitle))
|
||||||
{
|
{
|
||||||
entity.EpisodeTitle = episodeTitle;
|
// entity.EpisodeTitle = episodeTitle; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetBoolean(index++, out var isRepeat))
|
if (reader.TryGetBoolean(index++, out var isRepeat))
|
||||||
@@ -1021,12 +1036,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var audioString) && Enum.TryParse<ProgramAudioEntity>(audioString, out var audioType))
|
if (reader.TryGetString(index++, out var audioString) && Enum.TryParse<ProgramAudioEntity>(audioString, out var audioType))
|
||||||
{
|
{
|
||||||
entity.Audio = audioType;
|
// entity.Audio = audioType; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var serviceName))
|
if (reader.TryGetString(index++, out var serviceName))
|
||||||
{
|
{
|
||||||
entity.ExternalServiceId = serviceName;
|
// entity.ExternalServiceId = serviceName; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetBoolean(index++, out var isInMixedFolder))
|
if (reader.TryGetBoolean(index++, out var isInMixedFolder))
|
||||||
@@ -1090,17 +1105,17 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var album))
|
if (reader.TryGetString(index++, out var album))
|
||||||
{
|
{
|
||||||
entity.Album = album;
|
// entity.Album = album; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetSingle(index++, out var lUFS))
|
if (reader.TryGetSingle(index++, out var lUFS))
|
||||||
{
|
{
|
||||||
entity.LUFS = lUFS;
|
// entity.LUFS = lUFS; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetSingle(index++, out var normalizationGain))
|
if (reader.TryGetSingle(index++, out var normalizationGain))
|
||||||
{
|
{
|
||||||
entity.NormalizationGain = normalizationGain;
|
// entity.NormalizationGain = normalizationGain; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetSingle(index++, out var criticRating))
|
if (reader.TryGetSingle(index++, out var criticRating))
|
||||||
@@ -1115,7 +1130,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var seriesName))
|
if (reader.TryGetString(index++, out var seriesName))
|
||||||
{
|
{
|
||||||
entity.SeriesName = seriesName;
|
// entity.SeriesName = seriesName; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDataKeys = new List<string>();
|
var userDataKeys = new List<string>();
|
||||||
@@ -1126,17 +1141,17 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var seasonName))
|
if (reader.TryGetString(index++, out var seasonName))
|
||||||
{
|
{
|
||||||
entity.SeasonName = seasonName;
|
// entity.SeasonName = seasonName; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetGuid(index++, out var seasonId))
|
if (reader.TryGetGuid(index++, out var seasonId))
|
||||||
{
|
{
|
||||||
entity.SeasonId = seasonId;
|
// entity.SeasonId = seasonId; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetGuid(index++, out var seriesId))
|
if (reader.TryGetGuid(index++, out var seriesId))
|
||||||
{
|
{
|
||||||
entity.SeriesId = seriesId;
|
// entity.SeriesId = seriesId; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var presentationUniqueKey))
|
if (reader.TryGetString(index++, out var presentationUniqueKey))
|
||||||
@@ -1151,7 +1166,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var externalSeriesId))
|
if (reader.TryGetString(index++, out var externalSeriesId))
|
||||||
{
|
{
|
||||||
entity.ExternalSeriesId = externalSeriesId;
|
// entity.ExternalSeriesId = externalSeriesId; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var tagLine))
|
if (reader.TryGetString(index++, out var tagLine))
|
||||||
@@ -1199,12 +1214,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var artists))
|
if (reader.TryGetString(index++, out var artists))
|
||||||
{
|
{
|
||||||
entity.Artists = artists;
|
// entity.Artists = artists; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var albumArtists))
|
if (reader.TryGetString(index++, out var albumArtists))
|
||||||
{
|
{
|
||||||
entity.AlbumArtists = albumArtists;
|
// entity.AlbumArtists = albumArtists; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var externalId))
|
if (reader.TryGetString(index++, out var externalId))
|
||||||
@@ -1214,12 +1229,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var seriesPresentationUniqueKey))
|
if (reader.TryGetString(index++, out var seriesPresentationUniqueKey))
|
||||||
{
|
{
|
||||||
entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
|
// entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var showId))
|
if (reader.TryGetString(index++, out var showId))
|
||||||
{
|
{
|
||||||
entity.ShowId = showId;
|
// entity.ShowId = showId; // Property removed in new schema
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var ownerId))
|
if (reader.TryGetString(index++, out var ownerId))
|
||||||
|
|||||||
@@ -40,37 +40,43 @@ internal class MigrateRatingLevels : IDatabaseMigrationRoutine
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("Recalculating parental rating levels based on rating string.");
|
_logger.LogInformation("Recalculating parental rating levels based on rating string.");
|
||||||
using var context = _provider.CreateDbContext();
|
using var context = _provider.CreateDbContext();
|
||||||
using var transaction = context.Database.BeginTransaction();
|
|
||||||
|
|
||||||
// Materialize the ratings list first to avoid "command in progress" error
|
// NpgsqlRetryingExecutionStrategy requires all operations inside a user-initiated
|
||||||
var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct().ToList();
|
// transaction to be wrapped with CreateExecutionStrategy().Execute(...).
|
||||||
|
context.Database.CreateExecutionStrategy().Execute(() =>
|
||||||
foreach (var rating in ratings)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(rating))
|
using var transaction = context.Database.BeginTransaction();
|
||||||
{
|
|
||||||
int? value = null;
|
|
||||||
context.BaseItems
|
|
||||||
.Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
|
|
||||||
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value));
|
|
||||||
context.BaseItems
|
|
||||||
.Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
|
|
||||||
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var ratingValue = _localizationManager.GetRatingScore(rating);
|
|
||||||
var score = ratingValue?.Score;
|
|
||||||
var subScore = ratingValue?.SubScore;
|
|
||||||
context.BaseItems
|
|
||||||
.Where(e => e.OfficialRating == rating)
|
|
||||||
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score));
|
|
||||||
context.BaseItems
|
|
||||||
.Where(e => e.OfficialRating == rating)
|
|
||||||
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction.Commit();
|
// Materialize the ratings list first to avoid "command in progress" error
|
||||||
|
var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct().ToList();
|
||||||
|
|
||||||
|
foreach (var rating in ratings)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(rating))
|
||||||
|
{
|
||||||
|
int? value = null;
|
||||||
|
context.BaseItems
|
||||||
|
.Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
|
||||||
|
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value));
|
||||||
|
context.BaseItems
|
||||||
|
.Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
|
||||||
|
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ratingValue = _localizationManager.GetRatingScore(rating);
|
||||||
|
var score = ratingValue?.Score;
|
||||||
|
var subScore = ratingValue?.SubScore;
|
||||||
|
context.BaseItems
|
||||||
|
.Where(e => e.OfficialRating == rating)
|
||||||
|
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score));
|
||||||
|
context.BaseItems
|
||||||
|
.Where(e => e.OfficialRating == rating)
|
||||||
|
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
// <copyright file="MigrateUserDb.cs" company="PlaceholderCompany">
|
|
||||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
|
||||||
// </copyright>
|
|
||||||
|
|
||||||
namespace Jellyfin.Server.Migrations.Routines;
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using Emby.Server.Implementations.Data;
|
|
||||||
using Jellyfin.Data;
|
|
||||||
using Jellyfin.Database.Implementations;
|
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
|
||||||
using Jellyfin.Database.Implementations.Enums;
|
|
||||||
using Jellyfin.Extensions.Json;
|
|
||||||
using Jellyfin.Server.Implementations.Users;
|
|
||||||
using MediaBrowser.Controller;
|
|
||||||
using MediaBrowser.Controller.Entities;
|
|
||||||
using MediaBrowser.Model.Configuration;
|
|
||||||
using MediaBrowser.Model.Serialization;
|
|
||||||
using MediaBrowser.Model.Users;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The migration routine for migrating the user database to EF Core.
|
|
||||||
/// </summary>
|
|
||||||
#pragma warning disable CS0618 // Type or member is obsolete
|
|
||||||
[JellyfinMigration("2025-04-20T10:00:00", nameof(MigrateUserDb), "5C4B82A2-F053-4009-BD05-B6FCAD82F14C", RequiresSqlite = true)]
|
|
||||||
public class MigrateUserDb : IMigrationRoutine
|
|
||||||
#pragma warning restore CS0618 // Type or member is obsolete
|
|
||||||
{
|
|
||||||
private const string DbFilename = "users.db";
|
|
||||||
|
|
||||||
private readonly ILogger<MigrateUserDb> _logger;
|
|
||||||
private readonly IServerApplicationPaths _paths;
|
|
||||||
private readonly IDbContextFactory<JellyfinDbContext> _provider;
|
|
||||||
private readonly IXmlSerializer _xmlSerializer;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="MigrateUserDb"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="logger">The logger.</param>
|
|
||||||
/// <param name="paths">The server application paths.</param>
|
|
||||||
/// <param name="provider">The database provider.</param>
|
|
||||||
/// <param name="xmlSerializer">The xml serializer.</param>
|
|
||||||
public MigrateUserDb(
|
|
||||||
ILogger<MigrateUserDb> logger,
|
|
||||||
IServerApplicationPaths paths,
|
|
||||||
IDbContextFactory<JellyfinDbContext> provider,
|
|
||||||
IXmlSerializer xmlSerializer)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_paths = paths;
|
|
||||||
_provider = provider;
|
|
||||||
_xmlSerializer = xmlSerializer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public void Perform()
|
|
||||||
{
|
|
||||||
var dataPath = _paths.DataPath;
|
|
||||||
var userDbPath = Path.Combine(dataPath, DbFilename);
|
|
||||||
if (!File.Exists(userDbPath))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("{UserDbPath} doesn't exist, nothing to migrate", userDbPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
|
|
||||||
|
|
||||||
using (var connection = new SqliteConnection($"Filename={userDbPath}"))
|
|
||||||
{
|
|
||||||
connection.Open();
|
|
||||||
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='LocalUsersv2';");
|
|
||||||
foreach (var row in tableQuery)
|
|
||||||
{
|
|
||||||
if (row.GetInt32(0) == 0)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Table 'LocalUsersv2' doesn't exist in {UserDbPath}, nothing to migrate", userDbPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
using var dbContext = _provider.CreateDbContext();
|
|
||||||
|
|
||||||
var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
|
|
||||||
|
|
||||||
dbContext.RemoveRange(dbContext.Users);
|
|
||||||
dbContext.SaveChanges();
|
|
||||||
|
|
||||||
foreach (var entry in queryResult)
|
|
||||||
{
|
|
||||||
UserMockup? mockup = JsonSerializer.Deserialize<UserMockup>(entry.GetStream(2), JsonDefaults.Options);
|
|
||||||
if (mockup is null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var userDataDir = Path.Combine(_paths.UserConfigurationDirectoryPath, mockup.Name);
|
|
||||||
|
|
||||||
var configPath = Path.Combine(userDataDir, "config.xml");
|
|
||||||
var config = File.Exists(configPath)
|
|
||||||
? (UserConfiguration?)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), configPath) ?? new UserConfiguration()
|
|
||||||
: new UserConfiguration();
|
|
||||||
|
|
||||||
var policyPath = Path.Combine(userDataDir, "policy.xml");
|
|
||||||
var policy = File.Exists(policyPath)
|
|
||||||
? (UserPolicy?)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), policyPath) ?? new UserPolicy()
|
|
||||||
: new UserPolicy();
|
|
||||||
policy.AuthenticationProviderId = policy.AuthenticationProviderId?.Replace(
|
|
||||||
"Emby.Server.Implementations.Library",
|
|
||||||
"Jellyfin.Server.Implementations.Users",
|
|
||||||
StringComparison.Ordinal)
|
|
||||||
?? typeof(DefaultAuthenticationProvider).FullName;
|
|
||||||
|
|
||||||
policy.PasswordResetProviderId = typeof(DefaultPasswordResetProvider).FullName;
|
|
||||||
int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
|
|
||||||
{
|
|
||||||
-1 => null,
|
|
||||||
0 => 3,
|
|
||||||
_ => policy.LoginAttemptsBeforeLockout
|
|
||||||
};
|
|
||||||
|
|
||||||
var user = new User(mockup.Name, policy.AuthenticationProviderId!, policy.PasswordResetProviderId!)
|
|
||||||
{
|
|
||||||
Id = entry.GetGuid(1),
|
|
||||||
InternalId = entry.GetInt64(0),
|
|
||||||
MaxParentalRatingScore = policy.MaxParentalRating,
|
|
||||||
MaxParentalRatingSubScore = null,
|
|
||||||
EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess,
|
|
||||||
RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit,
|
|
||||||
InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount,
|
|
||||||
LoginAttemptsBeforeLockout = maxLoginAttempts,
|
|
||||||
SubtitleMode = config.SubtitleMode,
|
|
||||||
HidePlayedInLatest = config.HidePlayedInLatest,
|
|
||||||
EnableLocalPassword = config.EnableLocalPassword,
|
|
||||||
PlayDefaultAudioTrack = config.PlayDefaultAudioTrack,
|
|
||||||
DisplayCollectionsView = config.DisplayCollectionsView,
|
|
||||||
DisplayMissingEpisodes = config.DisplayMissingEpisodes,
|
|
||||||
AudioLanguagePreference = config.AudioLanguagePreference,
|
|
||||||
RememberAudioSelections = config.RememberAudioSelections,
|
|
||||||
EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay,
|
|
||||||
RememberSubtitleSelections = config.RememberSubtitleSelections,
|
|
||||||
SubtitleLanguagePreference = config.SubtitleLanguagePreference,
|
|
||||||
Password = mockup.Password,
|
|
||||||
LastLoginDate = mockup.LastLoginDate,
|
|
||||||
LastActivityDate = mockup.LastActivityDate
|
|
||||||
};
|
|
||||||
|
|
||||||
if (mockup.ImageInfos.Length > 0)
|
|
||||||
{
|
|
||||||
ItemImageInfo info = mockup.ImageInfos[0];
|
|
||||||
|
|
||||||
user.ProfileImage = new ImageInfo(info.Path)
|
|
||||||
{
|
|
||||||
LastModified = info.DateModified
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
|
|
||||||
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
|
|
||||||
user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
|
|
||||||
user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
|
|
||||||
user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
|
|
||||||
user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
|
|
||||||
user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
|
|
||||||
user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
|
|
||||||
user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
|
|
||||||
user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
|
|
||||||
user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
|
|
||||||
user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
|
|
||||||
user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
|
|
||||||
user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
|
|
||||||
user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
|
|
||||||
user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
|
|
||||||
user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
|
|
||||||
user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
|
|
||||||
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
|
|
||||||
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
|
|
||||||
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
|
|
||||||
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
|
|
||||||
|
|
||||||
foreach (var policyAccessSchedule in policy.AccessSchedules)
|
|
||||||
{
|
|
||||||
user.AccessSchedules.Add(policyAccessSchedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
|
|
||||||
user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
|
|
||||||
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
|
|
||||||
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
|
|
||||||
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
|
|
||||||
user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
|
|
||||||
user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
|
|
||||||
user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
|
|
||||||
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
|
|
||||||
|
|
||||||
dbContext.Users.Add(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
dbContext.SaveChanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.Move(Path.Combine(dataPath, DbFilename), Path.Combine(dataPath, DbFilename + ".old"));
|
|
||||||
|
|
||||||
var journalPath = Path.Combine(dataPath, DbFilename + "-journal");
|
|
||||||
if (File.Exists(journalPath))
|
|
||||||
{
|
|
||||||
File.Move(journalPath, Path.Combine(dataPath, DbFilename + ".old-journal"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (IOException e)
|
|
||||||
{
|
|
||||||
_logger.LogError(e, "Error renaming legacy user database to 'users.db.old'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
internal class UserMockup
|
|
||||||
{
|
|
||||||
public string Password { get; set; }
|
|
||||||
|
|
||||||
public string EasyPassword { get; set; }
|
|
||||||
|
|
||||||
public DateTime? LastLoginDate { get; set; }
|
|
||||||
|
|
||||||
public DateTime? LastActivityDate { get; set; }
|
|
||||||
|
|
||||||
public string Name { get; set; }
|
|
||||||
|
|
||||||
public ItemImageInfo[] ImageInfos { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,7 +57,7 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
|
|||||||
IDbContextFactory<JellyfinDbContext> dbProvider)
|
IDbContextFactory<JellyfinDbContext> dbProvider)
|
||||||
{
|
{
|
||||||
_appPaths = appPaths;
|
_appPaths = appPaths;
|
||||||
_logger = startupLogger.With(logger);
|
_logger = startupLogger.Attach(logger);
|
||||||
_pathManager = pathManager;
|
_pathManager = pathManager;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
_dbProvider = dbProvider;
|
_dbProvider = dbProvider;
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
// <copyright file="RemoveDuplicateExtras.cs" company="PlaceholderCompany">
|
|
||||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
|
||||||
// </copyright>
|
|
||||||
|
|
||||||
namespace Jellyfin.Server.Migrations.Routines;
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using Emby.Server.Implementations.Data;
|
|
||||||
using MediaBrowser.Controller;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Remove duplicate entries which were caused by a bug where a file was considered to be an "Extra" to itself.
|
|
||||||
/// </summary>
|
|
||||||
#pragma warning disable CS0618 // Type or member is obsolete
|
|
||||||
[JellyfinMigration("2025-04-20T08:00:00", nameof(RemoveDuplicateExtras), "ACBE17B7-8435-4A83-8B64-6FCF162CB9BD", RequiresSqlite = true)]
|
|
||||||
internal class RemoveDuplicateExtras : IMigrationRoutine
|
|
||||||
#pragma warning restore CS0618 // Type or member is obsolete
|
|
||||||
{
|
|
||||||
private const string DbFilename = "library.db";
|
|
||||||
private readonly ILogger<RemoveDuplicateExtras> _logger;
|
|
||||||
private readonly IServerApplicationPaths _paths;
|
|
||||||
|
|
||||||
public RemoveDuplicateExtras(ILogger<RemoveDuplicateExtras> logger, IServerApplicationPaths paths)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_paths = paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public void Perform()
|
|
||||||
{
|
|
||||||
var dataPath = _paths.DataPath;
|
|
||||||
var dbPath = Path.Combine(dataPath, DbFilename);
|
|
||||||
|
|
||||||
// Skip this migration if using PostgreSQL or if library.db doesn't exist
|
|
||||||
if (!File.Exists(dbPath))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("SQLite library.db not found, skipping SQLite-specific migration.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var connection = new SqliteConnection($"Filename={dbPath}");
|
|
||||||
connection.Open();
|
|
||||||
using (var transaction = connection.BeginTransaction())
|
|
||||||
{
|
|
||||||
// Query the database for the ids of duplicate extras
|
|
||||||
var queryResult = connection.Query("SELECT t1.Path FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video'");
|
|
||||||
var bads = string.Join(", ", queryResult.Select(x => x.GetString(0)));
|
|
||||||
|
|
||||||
// Do nothing if no duplicate extras were detected
|
|
||||||
if (bads.Length == 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("No duplicate extras detected, skipping migration.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back up the database before deleting any entries
|
|
||||||
for (int i = 1; ; i++)
|
|
||||||
{
|
|
||||||
var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
|
|
||||||
if (!File.Exists(bakPath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.Copy(dbPath, bakPath);
|
|
||||||
_logger.LogInformation("Library database backed up to {BackupPath}", bakPath);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete all duplicate extras
|
|
||||||
_logger.LogInformation("Removing found duplicated extras for the following items: {DuplicateExtras}", bads);
|
|
||||||
connection.Execute("DELETE FROM TypedBaseItems WHERE rowid IN (SELECT t1.rowid FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video')");
|
|
||||||
transaction.Commit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// <copyright file="ReseedFolderFlag.cs" company="PlaceholderCompany">
|
|
||||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
|
||||||
// </copyright>
|
|
||||||
|
|
||||||
#pragma warning disable RS0030 // Do not use banned APIs
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Emby.Server.Implementations.Data;
|
|
||||||
using Jellyfin.Database.Implementations;
|
|
||||||
using Jellyfin.Server.ServerSetupApp;
|
|
||||||
using MediaBrowser.Controller;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Jellyfin.Server.Migrations.Routines;
|
|
||||||
|
|
||||||
[JellyfinMigration("2025-07-30T21:50:00", nameof(ReseedFolderFlag), RequiresSqlite = true)]
|
|
||||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
|
||||||
internal class ReseedFolderFlag : IAsyncMigrationRoutine
|
|
||||||
{
|
|
||||||
private const string DbFilename = "library.db.old";
|
|
||||||
|
|
||||||
private readonly IStartupLogger _logger;
|
|
||||||
private readonly IServerApplicationPaths _paths;
|
|
||||||
private readonly IDbContextFactory<JellyfinDbContext> _provider;
|
|
||||||
|
|
||||||
public ReseedFolderFlag(
|
|
||||||
IStartupLogger<MigrateLibraryDb> startupLogger,
|
|
||||||
IDbContextFactory<JellyfinDbContext> provider,
|
|
||||||
IServerApplicationPaths paths)
|
|
||||||
{
|
|
||||||
_logger = startupLogger;
|
|
||||||
_provider = provider;
|
|
||||||
_paths = paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static bool RerunGuardFlag { get; set; } = false;
|
|
||||||
|
|
||||||
public async Task PerformAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (RerunGuardFlag)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Migration is skipped because it does not apply.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Migrating the IsFolder flag from library.db.old may take a while, do not stop Jellyfin.");
|
|
||||||
|
|
||||||
var dataPath = _paths.DataPath;
|
|
||||||
var libraryDbPath = Path.Combine(dataPath, DbFilename);
|
|
||||||
if (!File.Exists(libraryDbPath))
|
|
||||||
{
|
|
||||||
_logger.LogError("Cannot migrate IsFolder flag from {LibraryDb} as it does not exist. This migration expects the MigrateLibraryDb to run first.", libraryDbPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var dbContext = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
await using (dbContext.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
|
|
||||||
var queryResult = connection.Query(
|
|
||||||
"""
|
|
||||||
SELECT guid FROM TypedBaseItems
|
|
||||||
WHERE IsFolder = true
|
|
||||||
""")
|
|
||||||
.Select(entity => entity.GetGuid(0))
|
|
||||||
.ToList();
|
|
||||||
_logger.LogInformation("Migrating the IsFolder flag for {Count} items.", queryResult.Count);
|
|
||||||
foreach (var id in queryResult)
|
|
||||||
{
|
|
||||||
await dbContext.BaseItems.Where(e => e.Id == id).ExecuteUpdateAsync(e => e.SetProperty(f => f.IsFolder, true), cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -139,7 +139,7 @@ namespace Jellyfin.Server
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
|
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.Attach(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
|
||||||
|
|
||||||
StartupHelpers.PerformStaticInitialization();
|
StartupHelpers.PerformStaticInitialization();
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"Override": {
|
"Override": {
|
||||||
"Microsoft": "Warning",
|
"Microsoft": "Warning",
|
||||||
"System": "Warning",
|
"System": "Warning",
|
||||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
"Microsoft.EntityFrameworkCore.Database.Command": "Error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"WriteTo": [
|
"WriteTo": [
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public interface IStartupLogger : ILogger
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="logger">Other logger to rely messages to.</param>
|
/// <param name="logger">Other logger to rely messages to.</param>
|
||||||
/// <returns>A combined logger.</returns>
|
/// <returns>A combined logger.</returns>
|
||||||
IStartupLogger With(ILogger logger);
|
IStartupLogger Attach(ILogger logger);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens a new Group logger within the parent logger.
|
/// Opens a new Group logger within the parent logger.
|
||||||
@@ -37,7 +37,7 @@ public interface IStartupLogger : ILogger
|
|||||||
/// <param name="logger">Other logger to rely messages to.</param>
|
/// <param name="logger">Other logger to rely messages to.</param>
|
||||||
/// <returns>A combined logger.</returns>
|
/// <returns>A combined logger.</returns>
|
||||||
/// <typeparam name="TCategory">The logger cateogry.</typeparam>
|
/// <typeparam name="TCategory">The logger cateogry.</typeparam>
|
||||||
IStartupLogger<TCategory> With<TCategory>(ILogger logger);
|
IStartupLogger<TCategory> Attach<TCategory>(ILogger logger);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens a new Group logger within the parent logger.
|
/// Opens a new Group logger within the parent logger.
|
||||||
@@ -59,7 +59,7 @@ public interface IStartupLogger<TCategory> : IStartupLogger
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="logger">Other logger to rely messages to.</param>
|
/// <param name="logger">Other logger to rely messages to.</param>
|
||||||
/// <returns>A combined logger.</returns>
|
/// <returns>A combined logger.</returns>
|
||||||
new IStartupLogger<TCategory> With(ILogger logger);
|
new IStartupLogger<TCategory> Attach(ILogger logger);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens a new Group logger within the parent logger.
|
/// Opens a new Group logger within the parent logger.
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public class StartupLogger : IStartupLogger
|
public class StartupLogger : IStartupLogger
|
||||||
{
|
{
|
||||||
private readonly StartupLogTopic? _topic;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="StartupLogger"/> class.
|
/// Initializes a new instance of the <see cref="StartupLogger"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -21,6 +19,7 @@ public class StartupLogger : IStartupLogger
|
|||||||
public StartupLogger(ILogger logger)
|
public StartupLogger(ILogger logger)
|
||||||
{
|
{
|
||||||
BaseLogger = logger;
|
BaseLogger = logger;
|
||||||
|
Topic = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -30,13 +29,13 @@ public class StartupLogger : IStartupLogger
|
|||||||
/// <param name="topic">The group for this logger.</param>
|
/// <param name="topic">The group for this logger.</param>
|
||||||
internal StartupLogger(ILogger logger, StartupLogTopic? topic) : this(logger)
|
internal StartupLogger(ILogger logger, StartupLogTopic? topic) : this(logger)
|
||||||
{
|
{
|
||||||
_topic = topic;
|
Topic = topic;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static IStartupLogger Logger { get; set; } = new StartupLogger(NullLogger.Instance);
|
internal static IStartupLogger Logger { get; set; } = new StartupLogger(NullLogger.Instance);
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public StartupLogTopic? Topic => _topic;
|
public StartupLogTopic? Topic { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or Sets the underlying base logger.
|
/// Gets or Sets the underlying base logger.
|
||||||
@@ -50,13 +49,13 @@ public class StartupLogger : IStartupLogger
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public IStartupLogger With(ILogger logger)
|
public IStartupLogger Attach(ILogger logger)
|
||||||
{
|
{
|
||||||
return new StartupLogger(logger, Topic);
|
return new StartupLogger(logger, Topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public IStartupLogger<TCategory> With<TCategory>(ILogger logger)
|
public IStartupLogger<TCategory> Attach<TCategory>(ILogger logger)
|
||||||
{
|
{
|
||||||
return new StartupLogger<TCategory>(logger, Topic);
|
return new StartupLogger<TCategory>(logger, Topic);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class StartupLogger<TCategory> : StartupLogger, IStartupLogger<TCategory>
|
|||||||
return new StartupLogger<TCategory>(BaseLogger, startupEntry);
|
return new StartupLogger<TCategory>(BaseLogger, startupEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
IStartupLogger<TCategory> IStartupLogger<TCategory>.With(ILogger logger)
|
IStartupLogger<TCategory> IStartupLogger<TCategory>.Attach(ILogger logger)
|
||||||
{
|
{
|
||||||
return new StartupLogger<TCategory>(logger, Topic);
|
return new StartupLogger<TCategory>(logger, Topic);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-13
@@ -85,18 +85,25 @@ namespace Jellyfin.Server
|
|||||||
var acceptJsonHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json, 1.0);
|
var acceptJsonHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json, 1.0);
|
||||||
var acceptXmlHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml, 0.9);
|
var acceptXmlHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml, 0.9);
|
||||||
var acceptAnyHeader = new MediaTypeWithQualityHeaderValue("*/*", 0.8);
|
var acceptAnyHeader = new MediaTypeWithQualityHeaderValue("*/*", 0.8);
|
||||||
Func<IServiceProvider, HttpMessageHandler> eyeballsHttpClientHandlerDelegate = (_) => new SocketsHttpHandler()
|
|
||||||
{
|
|
||||||
AutomaticDecompression = DecompressionMethods.All,
|
|
||||||
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8,
|
|
||||||
ConnectCallback = HttpClientExtension.OnConnect
|
|
||||||
};
|
|
||||||
|
|
||||||
Func<IServiceProvider, HttpMessageHandler> defaultHttpClientHandlerDelegate = (_) => new SocketsHttpHandler()
|
HttpMessageHandler CreateEyeballsHttpClientHandler(IServiceProvider serviceProvider)
|
||||||
{
|
{
|
||||||
AutomaticDecompression = DecompressionMethods.All,
|
return new SocketsHttpHandler()
|
||||||
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8
|
{
|
||||||
};
|
AutomaticDecompression = DecompressionMethods.All,
|
||||||
|
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8,
|
||||||
|
ConnectCallback = HttpClientExtension.OnConnect
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpMessageHandler CreateDefaultHttpClientHandler(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
return new SocketsHttpHandler()
|
||||||
|
{
|
||||||
|
AutomaticDecompression = DecompressionMethods.All,
|
||||||
|
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
services.AddHttpClient(NamedClient.Default, c =>
|
services.AddHttpClient(NamedClient.Default, c =>
|
||||||
{
|
{
|
||||||
@@ -105,7 +112,7 @@ namespace Jellyfin.Server
|
|||||||
c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
|
c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
|
||||||
c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
|
c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
|
||||||
})
|
})
|
||||||
.ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate);
|
.ConfigurePrimaryHttpMessageHandler(CreateEyeballsHttpClientHandler);
|
||||||
|
|
||||||
services.AddHttpClient(NamedClient.MusicBrainz, c =>
|
services.AddHttpClient(NamedClient.MusicBrainz, c =>
|
||||||
{
|
{
|
||||||
@@ -114,7 +121,7 @@ namespace Jellyfin.Server
|
|||||||
c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
|
c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
|
||||||
c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
|
c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
|
||||||
})
|
})
|
||||||
.ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate);
|
.ConfigurePrimaryHttpMessageHandler(CreateEyeballsHttpClientHandler);
|
||||||
|
|
||||||
services.AddHttpClient(NamedClient.DirectIp, c =>
|
services.AddHttpClient(NamedClient.DirectIp, c =>
|
||||||
{
|
{
|
||||||
@@ -123,7 +130,7 @@ namespace Jellyfin.Server
|
|||||||
c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
|
c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
|
||||||
c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
|
c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
|
||||||
})
|
})
|
||||||
.ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate);
|
.ConfigurePrimaryHttpMessageHandler(CreateDefaultHttpClientHandler);
|
||||||
|
|
||||||
services.AddHealthChecks()
|
services.AddHealthChecks()
|
||||||
.AddCheck<DbContextFactoryHealthCheck<JellyfinDbContext>>(nameof(JellyfinDbContext));
|
.AddCheck<DbContextFactoryHealthCheck<JellyfinDbContext>>(nameof(JellyfinDbContext));
|
||||||
@@ -168,6 +175,8 @@ namespace Jellyfin.Server
|
|||||||
|
|
||||||
mainApp.UseWebSockets();
|
mainApp.UseWebSockets();
|
||||||
|
|
||||||
|
mainApp.UseWebSocketAuthentication();
|
||||||
|
|
||||||
mainApp.UseResponseCompression();
|
mainApp.UseResponseCompression();
|
||||||
|
|
||||||
mainApp.UseCors();
|
mainApp.UseCors();
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# SYNOPSIS
|
||||||
|
# Automated weekly performance monitoring for Jellyfin PostgreSQL database
|
||||||
|
#
|
||||||
|
# DESCRIPTION
|
||||||
|
# This script:
|
||||||
|
# 1. Runs comprehensive diagnostics
|
||||||
|
# 2. Analyzes slow queries
|
||||||
|
# 3. Tracks index usage over time
|
||||||
|
# 4. Generates weekly performance reports
|
||||||
|
# 5. Optionally runs VACUUM ANALYZE on tables with high dead tuples
|
||||||
|
# 6. Can be scheduled via cron
|
||||||
|
#
|
||||||
|
# USAGE
|
||||||
|
# ./Weekly-Performance-Monitor.sh [OPTIONS]
|
||||||
|
#
|
||||||
|
# OPTIONS
|
||||||
|
# -o, --output-directory DIR Directory to save reports (default: reports/weekly)
|
||||||
|
# -e, --email-report If specified, emails the report (requires email configuration)
|
||||||
|
# -c, --compare-with-previous Compare current stats with previous week
|
||||||
|
# -a, --auto-vacuum-analyze Enable auto vacuum analyze
|
||||||
|
# --dead-tuple-threshold NUM Minimum dead tuples to trigger vacuum (default: 100)
|
||||||
|
# --dead-tuple-percent-threshold NUM Minimum dead tuple percentage to trigger vacuum (default: 0.02)
|
||||||
|
#
|
||||||
|
# EXAMPLE
|
||||||
|
# ./Weekly-Performance-Monitor.sh
|
||||||
|
# ./Weekly-Performance-Monitor.sh -o "/reports/jellyfin" -c
|
||||||
|
# ./Weekly-Performance-Monitor.sh -a --dead-tuple-threshold 10000 --dead-tuple-percent-threshold 20
|
||||||
|
#
|
||||||
|
# CRON JOB EXAMPLE
|
||||||
|
# # Run every Monday at 6am
|
||||||
|
# 0 6 * * 1 /path/to/Weekly-Performance-Monitor.sh -a
|
||||||
|
#
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Default Configuration
|
||||||
|
# ============================================================================
|
||||||
|
OUTPUT_DIRECTORY="reports/weekly"
|
||||||
|
EMAIL_REPORT=false
|
||||||
|
COMPARE_WITH_PREVIOUS=false
|
||||||
|
AUTO_VACUUM_ANALYZE=false
|
||||||
|
DEAD_TUPLE_THRESHOLD=100
|
||||||
|
DEAD_TUPLE_PERCENT_THRESHOLD=0.02
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helper Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Color definitions
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GRAY='\033[0;90m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
write_color_output() {
|
||||||
|
local message="$1"
|
||||||
|
local color="$2"
|
||||||
|
echo -e "${color}${message}${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_report_timestamp() {
|
||||||
|
date +"%Y-%m-%d_%H%M%S"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_week_number() {
|
||||||
|
date +%V
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Script Argument Parsing
|
||||||
|
# ============================================================================
|
||||||
|
while [[ "$#" -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-o|--output-directory) OUTPUT_DIRECTORY="$2"; shift ;;
|
||||||
|
-e|--email-report) EMAIL_REPORT=true ;;
|
||||||
|
-c|--compare-with-previous) COMPARE_WITH_PREVIOUS=true ;;
|
||||||
|
-a|--auto-vacuum-analyze) AUTO_VACUUM_ANALYZE=true ;;
|
||||||
|
--dead-tuple-threshold) DEAD_TUPLE_THRESHOLD="$2"; shift ;;
|
||||||
|
--dead-tuple-percent-threshold) DEAD_TUPLE_PERCENT_THRESHOLD="$2"; shift ;;
|
||||||
|
*) write_color_output "Unknown parameter passed: $1" "$RED"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Configuration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||||
|
SQL_DIR="$PROJECT_ROOT/sql"
|
||||||
|
|
||||||
|
# Load database configuration
|
||||||
|
source "./db-config.sh"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
test_database_connection() {
|
||||||
|
write_color_output "\nTesting database connection..." "$CYAN"
|
||||||
|
|
||||||
|
if PGPASSWORD=$DB_PASS $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -c "SELECT version();" > /dev/null 2>&1; then
|
||||||
|
write_color_output "[OK] Database connection successful" "$GREEN"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
write_color_output "[ERROR] Database connection failed" "$RED"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
invoke_diagnostic_report() {
|
||||||
|
local output_file="$1"
|
||||||
|
write_color_output "\nRunning comprehensive diagnostics..." "$CYAN"
|
||||||
|
|
||||||
|
run_sql_files_from_dir "$SQL_DIR/diagnostics" "$output_file"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
write_color_output "[OK] Diagnostics completed: $output_file" "$GREEN"
|
||||||
|
else
|
||||||
|
write_color_output "[WARN] Diagnostics completed with warnings" "$YELLOW"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_index_usage_stats() {
|
||||||
|
local output_file="$1"
|
||||||
|
write_color_output "\nGathering index usage statistics..." "$CYAN"
|
||||||
|
|
||||||
|
local query="
|
||||||
|
SELECT
|
||||||
|
schemaname,
|
||||||
|
relname AS tablename,
|
||||||
|
indexrelname,
|
||||||
|
idx_scan,
|
||||||
|
idx_tup_read,
|
||||||
|
idx_tup_fetch,
|
||||||
|
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
|
||||||
|
FROM pg_stat_user_indexes
|
||||||
|
WHERE schemaname IN ('library', 'users', 'authentication', 'displaypreferences', 'activitylog')
|
||||||
|
ORDER BY schemaname, relname, idx_scan DESC;
|
||||||
|
"
|
||||||
|
|
||||||
|
if PGPASSWORD=$DB_PASS $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -c "$query" > "$output_file" 2>&1; then
|
||||||
|
write_color_output "[OK] Index stats gathered: $output_file" "$GREEN"
|
||||||
|
else
|
||||||
|
write_color_output "[WARN] Index stats completed with warnings" "$YELLOW"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_table_size_stats() {
|
||||||
|
local output_file="$1"
|
||||||
|
write_color_output "\nGathering table size statistics..." "$CYAN"
|
||||||
|
|
||||||
|
local query="
|
||||||
|
SELECT
|
||||||
|
schemaname,
|
||||||
|
relname,
|
||||||
|
pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname)) AS total_size,
|
||||||
|
pg_size_pretty(pg_relation_size(schemaname||'.'||relname)) AS table_size,
|
||||||
|
pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname) - pg_relation_size(schemaname||'.'||relname)) AS index_size,
|
||||||
|
n_live_tup AS row_count,
|
||||||
|
n_dead_tup AS dead_rows,
|
||||||
|
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 2) AS dead_pct
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname IN ('library', 'users', 'authentication', 'displaypreferences', 'activitylog')
|
||||||
|
ORDER BY pg_total_relation_size(schemaname||'.'||relname) DESC;
|
||||||
|
"
|
||||||
|
|
||||||
|
if PGPASSWORD=$DB_PASS $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -c "$query" > "$output_file" 2>&1; then
|
||||||
|
write_color_output "[OK] Table size stats gathered: $output_file" "$GREEN"
|
||||||
|
else
|
||||||
|
write_color_output "[WARN] Table size stats completed with warnings" "$YELLOW"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
invoke_conditional_vacuum_analyze() {
|
||||||
|
local min_dead_tuples="$1"
|
||||||
|
local min_dead_percent="$2"
|
||||||
|
|
||||||
|
write_color_output "\nChecking for tables that need VACUUM ANALYZE..." "$CYAN"
|
||||||
|
write_color_output "Thresholds: dead tuples >= $min_dead_tuples and dead percent >= $min_dead_percent" "$GRAY"
|
||||||
|
|
||||||
|
local table_query="
|
||||||
|
SELECT
|
||||||
|
format('%I.%I', schemaname, relname) AS qualified_table,
|
||||||
|
n_dead_tup,
|
||||||
|
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 2) AS dead_pct
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname IN ('library', 'users', 'authentication', 'displaypreferences', 'activitylog')
|
||||||
|
AND n_live_tup > 0
|
||||||
|
AND n_dead_tup >= $min_dead_tuples
|
||||||
|
AND (100.0 * n_dead_tup / NULLIF(n_live_tup, 0)) >= $min_dead_percent
|
||||||
|
ORDER BY n_dead_tup DESC;
|
||||||
|
"
|
||||||
|
|
||||||
|
tables=$(PGPASSWORD=$DB_PASS $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -t -A -F "|" -c "$table_query")
|
||||||
|
|
||||||
|
if [ -z "$tables" ]; then
|
||||||
|
write_color_output "[OK] No tables exceeded dead tuple thresholds" "$GREEN"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_color_output "Found $(echo "$tables" | wc -l) table(s) requiring VACUUM ANALYZE" "$YELLOW"
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
qualified_table=$(echo "$line" | cut -d'|' -f1)
|
||||||
|
dead_tuples=$(echo "$line" | cut -d'|' -f2)
|
||||||
|
dead_percent=$(echo "$line" | cut -d'|' -f3)
|
||||||
|
|
||||||
|
write_color_output "Running VACUUM ANALYZE on $qualified_table (dead tuples: $dead_tuples, dead percent: $dead_percent)" "$CYAN"
|
||||||
|
|
||||||
|
if PGPASSWORD=$DB_PASS $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -c "VACUUM (ANALYZE, VERBOSE) $qualified_table;"; then
|
||||||
|
write_color_output "[OK] VACUUM ANALYZE completed for $qualified_table" "$GREEN"
|
||||||
|
else
|
||||||
|
write_color_output "[ERROR] VACUUM ANALYZE failed for $qualified_table" "$RED"
|
||||||
|
fi
|
||||||
|
done <<< "$tables"
|
||||||
|
}
|
||||||
|
|
||||||
|
compare_with_previous() {
|
||||||
|
local current_report="$1"
|
||||||
|
local output_dir="$2"
|
||||||
|
|
||||||
|
write_color_output "\nComparing with previous week's report..." "$CYAN"
|
||||||
|
|
||||||
|
previous_report=$(find "$output_dir" -name "diagnostics_*.txt" ! -name "$(basename "$current_report")" -printf '%T@ %p\n' | sort -n | tail -1 | cut -d' ' -f2-)
|
||||||
|
|
||||||
|
if [ -z "$previous_report" ]; then
|
||||||
|
write_color_output "[WARN] No previous reports found for comparison" "$YELLOW"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_color_output "Comparing with: $(basename "$previous_report")" "$CYAN"
|
||||||
|
|
||||||
|
# TODO: Add detailed comparison logic
|
||||||
|
write_color_output "[OK] Comparison data available" "$GREEN"
|
||||||
|
write_color_output " Current: $current_report" "$GRAY"
|
||||||
|
write_color_output " Previous: $previous_report" "$GRAY"
|
||||||
|
}
|
||||||
|
|
||||||
|
new_summary_report() {
|
||||||
|
local output_dir="$1"
|
||||||
|
local timestamp="$2"
|
||||||
|
|
||||||
|
write_color_output "\nGenerating summary report..." "$CYAN"
|
||||||
|
|
||||||
|
local summary_file="$output_dir/summary_$timestamp.txt"
|
||||||
|
|
||||||
|
cat > "$summary_file" <<EOF
|
||||||
|
================================================================================
|
||||||
|
JELLYFIN DATABASE WEEKLY PERFORMANCE SUMMARY
|
||||||
|
================================================================================
|
||||||
|
Report Date: $(date +"%Y-%m-%d %H:%M:%S")
|
||||||
|
Database: $DB_NAME at $DB_HOST
|
||||||
|
Week Number: $(get_week_number)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
REPORTS GENERATED:
|
||||||
|
* diagnostics_$timestamp.txt : Full diagnostic report
|
||||||
|
* index_usage_$timestamp.txt : Index usage statistics
|
||||||
|
* table_sizes_$timestamp.txt : Table size and bloat info
|
||||||
|
* summary_$timestamp.txt : This summary
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
QUICK HEALTH CHECK:
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
To view critical issues check diagnostics file for:
|
||||||
|
* Section 5: TABLES WITH HIGH SEQUENTIAL SCANS
|
||||||
|
* Section 8: UNUSED OR RARELY USED INDEXES
|
||||||
|
* Section 10: SLOWEST QUERIES
|
||||||
|
|
||||||
|
To identify slow queries check query_analysis file for:
|
||||||
|
* Section 2: TOP 20 SLOWEST QUERIES (by max execution time)
|
||||||
|
* Section 5: BASEITEMS TABLE QUERIES ANALYSIS
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
RECOMMENDED ACTIONS:
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
* Review slow queries taking longer than 10 seconds
|
||||||
|
* Check if new indexes are being utilized
|
||||||
|
* Look for tables with many sequential scans
|
||||||
|
* Monitor cache hit ratio (should be above 95 percent)
|
||||||
|
* Check for tables with high dead tuples percentage (need VACUUM)
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
AUTOMATION SETUP:
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
To schedule this script weekly, add the following to your crontab:
|
||||||
|
0 6 * * 1 /path/to/$(basename "$0")
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
NEXT REPORT: $(date -d "+7 days" +"%Y-%m-%d")
|
||||||
|
================================================================================
|
||||||
|
EOF
|
||||||
|
|
||||||
|
write_color_output "[OK] Summary generated: $summary_file" "$GREEN"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helper Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
run_sql_files_from_dir() {
|
||||||
|
local sql_dir="$1"
|
||||||
|
local output_file="$2"
|
||||||
|
|
||||||
|
if [ ! -d "$sql_dir" ]; then
|
||||||
|
write_color_output "[ERROR] Directory not found: $sql_dir" "$RED"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clear the output file before running scripts
|
||||||
|
> "$output_file"
|
||||||
|
|
||||||
|
for sql_file in "$sql_dir"/*.sql; do
|
||||||
|
echo -e "\n---\n-- Executing: $(basename "$sql_file")\n---\n" >> "$output_file"
|
||||||
|
write_color_output "Running SQL file: $(basename "$sql_file")" "$CYAN"
|
||||||
|
if PGPASSWORD=$DB_PASS $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f "$sql_file" >> "$output_file" 2>&1; then
|
||||||
|
write_color_output "[OK] Successfully executed $(basename "$sql_file")" "$GREEN"
|
||||||
|
else
|
||||||
|
write_color_output "[WARN] Completed with warnings: $(basename "$sql_file")" "$YELLOW"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Main Execution
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
write_color_output "\n${CYAN}========================================${NC}"
|
||||||
|
write_color_output "Jellyfin Database Weekly Monitor" "$CYAN"
|
||||||
|
write_color_output "========================================${NC}"
|
||||||
|
write_color_output "Database: $DB_NAME" "$YELLOW"
|
||||||
|
write_color_output "Host: $DB_HOST" "$YELLOW"
|
||||||
|
write_color_output "Week: $(get_week_number)" "$YELLOW"
|
||||||
|
write_color_output "Date: $(date +'%Y-%m-%d %H:%M:%S')" "$YELLOW"
|
||||||
|
write_color_output "========================================\n${NC}"
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
report_dir="./$OUTPUT_DIRECTORY"
|
||||||
|
mkdir -p "$report_dir"
|
||||||
|
write_color_output "Created report directory: $report_dir" "$GREEN"
|
||||||
|
|
||||||
|
# Generate timestamp for this run
|
||||||
|
timestamp=$(get_report_timestamp)
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
if ! test_database_connection; then
|
||||||
|
write_color_output "\n[ERROR] Cannot connect to database. Exiting." "$RED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run diagnostics
|
||||||
|
diagnostics_file="$report_dir/diagnostics_$timestamp.txt"
|
||||||
|
invoke_diagnostic_report "$diagnostics_file"
|
||||||
|
|
||||||
|
# Gather index usage stats
|
||||||
|
index_usage_file="$report_dir/index_usage_$timestamp.txt"
|
||||||
|
get_index_usage_stats "$index_usage_file"
|
||||||
|
|
||||||
|
# Gather table size stats
|
||||||
|
table_sizes_file="$report_dir/table_sizes_$timestamp.txt"
|
||||||
|
get_table_size_stats "$table_sizes_file"
|
||||||
|
|
||||||
|
# Optionally vacuum/analyze tables
|
||||||
|
if [ "$AUTO_VACUUM_ANALYZE" = true ]; then
|
||||||
|
invoke_conditional_vacuum_analyze "$DEAD_TUPLE_THRESHOLD" "$DEAD_TUPLE_PERCENT_THRESHOLD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Compare with previous week
|
||||||
|
if [ "$COMPARE_WITH_PREVIOUS" = true ]; then
|
||||||
|
compare_with_previous "$diagnostics_file" "$report_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate summary
|
||||||
|
new_summary_report "$report_dir" "$timestamp"
|
||||||
|
|
||||||
|
# Cleanup old reports (keep last 12 weeks)
|
||||||
|
write_color_output "\nCleaning up old reports (keeping last 12 weeks)..." "$CYAN"
|
||||||
|
find "$report_dir" -type f -mtime +84 -delete
|
||||||
|
write_color_output "[OK] Cleanup complete" "$GREEN"
|
||||||
|
|
||||||
|
write_color_output "\n${GREEN}========================================${NC}"
|
||||||
|
write_color_output "[OK] WEEKLY MONITORING COMPLETE" "$GREEN"
|
||||||
|
write_color_output "========================================${NC}"
|
||||||
|
write_color_output "Reports saved to: $report_dir" "$YELLOW"
|
||||||
|
write_color_output "Summary: summary_$timestamp.txt" "$YELLOW"
|
||||||
|
write_color_output "\nNext steps:" "$CYAN"
|
||||||
|
write_color_output "1. Review summary_$timestamp.txt" "$NC"
|
||||||
|
write_color_output "2. Check for critical issues in diagnostics report" "$NC"
|
||||||
|
write_color_output "3. Analyze slow queries in query_analysis report" "$NC"
|
||||||
|
write_color_output "========================================\n${NC}"
|
||||||
|
|
||||||
|
# Email report if requested
|
||||||
|
if [ "$EMAIL_REPORT" = true ]; then
|
||||||
|
write_color_output "[WARN] Email functionality not yet implemented" "$YELLOW"
|
||||||
|
write_color_output " Configure a mail client like 'mail' or 'sendmail'" "$GRAY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Database Configuration
|
||||||
|
# Edit this file to change which database commands run against
|
||||||
|
|
||||||
|
# Database connection settings
|
||||||
|
PSQL_PATH="/usr/bin/psql"
|
||||||
|
DB_USER="jellyfin"
|
||||||
|
DB_NAME="jellyfin_test2" # ← Change this to switch databases
|
||||||
|
DB_HOST="192.168.129.253"
|
||||||
|
DB_PORT="6432"
|
||||||
|
|
||||||
|
# Export for use in other scripts
|
||||||
|
export PSQL_PATH
|
||||||
|
export DB_USER
|
||||||
|
export DB_NAME
|
||||||
|
export DB_HOST
|
||||||
|
export DB_PORT
|
||||||
|
|
||||||
|
# Helper function to run psql commands
|
||||||
|
invoke_psql() {
|
||||||
|
local query=""
|
||||||
|
local file=""
|
||||||
|
local output_file=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-q|--query)
|
||||||
|
query="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-f|--file)
|
||||||
|
file="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-o|--output-file)
|
||||||
|
output_file="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
local base_cmd="$PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME"
|
||||||
|
local cmd=""
|
||||||
|
|
||||||
|
if [ -n "$file" ]; then
|
||||||
|
cmd="$base_cmd -f \"$file\""
|
||||||
|
elif [ -n "$query" ]; then
|
||||||
|
cmd="$base_cmd -c \"$query\""
|
||||||
|
else
|
||||||
|
echo "Must provide either --query or --file parameter"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$output_file" ]; then
|
||||||
|
cmd+=" > \"$output_file\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\e[36mConnecting to: $DB_NAME@$DB_HOST as $DB_USER\e[0m"
|
||||||
|
eval "$cmd"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display current configuration
|
||||||
|
echo -e "\e[36m========================================\e[0m"
|
||||||
|
echo -e "\e[36mDatabase Configuration Loaded\e[0m"
|
||||||
|
echo -e "\e[36m========================================\e[0m"
|
||||||
|
echo -e "\e[33mDatabase: $DB_NAME\e[0m"
|
||||||
|
echo -e "\e[33mUser: $DB_USER\e[0m"
|
||||||
|
echo -e "\e[33mHost: $DB_HOST\e[0m"
|
||||||
|
echo -e "\e[33mPort: $DB_PORT\e[0m"
|
||||||
|
echo -e "\e[36m========================================\e[0m"
|
||||||
|
echo ""
|
||||||
|
echo -e "\e[90mTo change database, edit: db-config.sh\e[0m"
|
||||||
|
echo -e "\e[90mThen run: source db-config.sh\e[0m"
|
||||||
|
echo ""
|
||||||
+37
-3
@@ -6,7 +6,8 @@
|
|||||||
Sets up automated weekly performance monitoring via Windows Task Scheduler
|
Sets up automated weekly performance monitoring via Windows Task Scheduler
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Creates a scheduled task to run Weekly-Performance-Monitor.ps1 every Monday at 6 AM
|
Creates a scheduled task to run Weekly-Performance-Monitor.ps1 every Monday at 6 AM.
|
||||||
|
Can optionally enable conditional VACUUM ANALYZE based on dead tuple thresholds.
|
||||||
|
|
||||||
.PARAMETER Uninstall
|
.PARAMETER Uninstall
|
||||||
Remove the scheduled task
|
Remove the scheduled task
|
||||||
@@ -17,12 +18,24 @@
|
|||||||
.PARAMETER DayOfWeek
|
.PARAMETER DayOfWeek
|
||||||
Day of week to run (default: Monday)
|
Day of week to run (default: Monday)
|
||||||
|
|
||||||
|
.PARAMETER EnableAutoVacuumAnalyze
|
||||||
|
Enable conditional VACUUM ANALYZE for tables above dead tuple thresholds
|
||||||
|
|
||||||
|
.PARAMETER DeadTupleThreshold
|
||||||
|
Minimum dead tuples before VACUUM ANALYZE runs (default: 10000)
|
||||||
|
|
||||||
|
.PARAMETER DeadTuplePercentThreshold
|
||||||
|
Minimum dead tuple percentage before VACUUM ANALYZE runs (default: 20)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\Setup-Weekly-Monitor.ps1
|
.\Setup-Weekly-Monitor.ps1
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\Setup-Weekly-Monitor.ps1 -TaskTime "3am" -DayOfWeek Sunday
|
.\Setup-Weekly-Monitor.ps1 -TaskTime "3am" -DayOfWeek Sunday
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Setup-Weekly-Monitor.ps1 -EnableAutoVacuumAnalyze -DeadTupleThreshold 10000 -DeadTuplePercentThreshold 20
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\Setup-Weekly-Monitor.ps1 -Uninstall
|
.\Setup-Weekly-Monitor.ps1 -Uninstall
|
||||||
#>
|
#>
|
||||||
@@ -37,7 +50,18 @@ param (
|
|||||||
|
|
||||||
[Parameter()]
|
[Parameter()]
|
||||||
[ValidateSet('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday')]
|
[ValidateSet('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday')]
|
||||||
[string]$DayOfWeek = 'Monday'
|
[string]$DayOfWeek = 'Monday',
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[switch]$EnableAutoVacuumAnalyze,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[ValidateRange(1, [int]::MaxValue)]
|
||||||
|
[int]$DeadTupleThreshold = 10000,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[ValidateRange(0.01, 100.0)]
|
||||||
|
[double]$DeadTuplePercentThreshold = 20.0
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -104,6 +128,11 @@ Write-ColorOutput "Configuration:" -Color Cyan
|
|||||||
Write-ColorOutput " Task Name: $TaskName" -Color White
|
Write-ColorOutput " Task Name: $TaskName" -Color White
|
||||||
Write-ColorOutput " Script: $MonitorScript" -Color White
|
Write-ColorOutput " Script: $MonitorScript" -Color White
|
||||||
Write-ColorOutput " Schedule: Every $DayOfWeek at $TaskTime" -Color White
|
Write-ColorOutput " Schedule: Every $DayOfWeek at $TaskTime" -Color White
|
||||||
|
if ($EnableAutoVacuumAnalyze) {
|
||||||
|
Write-ColorOutput " Auto VACUUM ANALYZE: Enabled (dead tuples >= $DeadTupleThreshold, dead percent >= $DeadTuplePercentThreshold)" -Color White
|
||||||
|
} else {
|
||||||
|
Write-ColorOutput " Auto VACUUM ANALYZE: Disabled" -Color White
|
||||||
|
}
|
||||||
Write-ColorOutput ""
|
Write-ColorOutput ""
|
||||||
|
|
||||||
# Remove existing task if present
|
# Remove existing task if present
|
||||||
@@ -116,9 +145,14 @@ if ($existingTask) {
|
|||||||
# Create scheduled task action
|
# Create scheduled task action
|
||||||
Write-ColorOutput "Creating scheduled task..." -Color Cyan
|
Write-ColorOutput "Creating scheduled task..." -Color Cyan
|
||||||
|
|
||||||
|
$scriptArguments = "-NoProfile -ExecutionPolicy Bypass -File `"$MonitorScript`""
|
||||||
|
if ($EnableAutoVacuumAnalyze) {
|
||||||
|
$scriptArguments += " -AutoVacuumAnalyze -DeadTupleThreshold $DeadTupleThreshold -DeadTuplePercentThreshold $DeadTuplePercentThreshold"
|
||||||
|
}
|
||||||
|
|
||||||
$action = New-ScheduledTaskAction `
|
$action = New-ScheduledTaskAction `
|
||||||
-Execute "PowerShell.exe" `
|
-Execute "PowerShell.exe" `
|
||||||
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$MonitorScript`""
|
-Argument $scriptArguments
|
||||||
|
|
||||||
# Create trigger
|
# Create trigger
|
||||||
$trigger = New-ScheduledTaskTrigger `
|
$trigger = New-ScheduledTaskTrigger `
|
||||||
+97
-10
@@ -10,7 +10,8 @@
|
|||||||
2. Analyzes slow queries
|
2. Analyzes slow queries
|
||||||
3. Tracks index usage over time
|
3. Tracks index usage over time
|
||||||
4. Generates weekly performance reports
|
4. Generates weekly performance reports
|
||||||
5. Can be scheduled via Task Scheduler
|
5. Optionally runs VACUUM ANALYZE on tables with high dead tuples
|
||||||
|
6. Can be scheduled via Task Scheduler
|
||||||
|
|
||||||
.PARAMETER OutputDirectory
|
.PARAMETER OutputDirectory
|
||||||
Directory to save reports (default: reports/weekly)
|
Directory to save reports (default: reports/weekly)
|
||||||
@@ -27,6 +28,9 @@
|
|||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\Weekly-Performance-Monitor.ps1 -OutputDirectory "C:\Reports\Jellyfin" -CompareWithPrevious
|
.\Weekly-Performance-Monitor.ps1 -OutputDirectory "C:\Reports\Jellyfin" -CompareWithPrevious
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Weekly-Performance-Monitor.ps1 -AutoVacuumAnalyze -DeadTupleThreshold 10000 -DeadTuplePercentThreshold 20
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
# Schedule weekly via Task Scheduler
|
# Schedule weekly via Task Scheduler
|
||||||
$action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-File C:\Path\To\Weekly-Performance-Monitor.ps1"
|
$action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-File C:\Path\To\Weekly-Performance-Monitor.ps1"
|
||||||
@@ -47,7 +51,18 @@ param (
|
|||||||
[switch]$EmailReport,
|
[switch]$EmailReport,
|
||||||
|
|
||||||
[Parameter()]
|
[Parameter()]
|
||||||
[switch]$CompareWithPrevious
|
[switch]$CompareWithPrevious,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[switch]$AutoVacuumAnalyze,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[ValidateRange(1, [int]::MaxValue)]
|
||||||
|
[int]$DeadTupleThreshold = 100,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[ValidateRange(0.01, 100.0)]
|
||||||
|
[double]$DeadTuplePercentThreshold = 0.02
|
||||||
)
|
)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -154,15 +169,15 @@ function Get-IndexUsageStats {
|
|||||||
$query = @"
|
$query = @"
|
||||||
SELECT
|
SELECT
|
||||||
schemaname,
|
schemaname,
|
||||||
tablename,
|
relname AS tablename,
|
||||||
indexname,
|
indexrelname,
|
||||||
idx_scan,
|
idx_scan,
|
||||||
idx_tup_read,
|
idx_tup_read,
|
||||||
idx_tup_fetch,
|
idx_tup_fetch,
|
||||||
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
|
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
|
||||||
FROM pg_stat_user_indexes
|
FROM pg_stat_user_indexes
|
||||||
WHERE schemaname IN ('library', 'users', 'authentication', 'displaypreferences', 'activitylog')
|
WHERE schemaname IN ('library', 'users', 'authentication', 'displaypreferences', 'activitylog')
|
||||||
ORDER BY schemaname, tablename, idx_scan DESC;
|
ORDER BY schemaname, relname, idx_scan DESC;
|
||||||
"@
|
"@
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -189,16 +204,16 @@ function Get-TableSizeStats {
|
|||||||
$query = @"
|
$query = @"
|
||||||
SELECT
|
SELECT
|
||||||
schemaname,
|
schemaname,
|
||||||
tablename,
|
relname,
|
||||||
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
|
pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname)) AS total_size,
|
||||||
pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) AS table_size,
|
pg_size_pretty(pg_relation_size(schemaname||'.'||relname)) AS table_size,
|
||||||
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename) - pg_relation_size(schemaname||'.'||tablename)) AS index_size,
|
pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname) - pg_relation_size(schemaname||'.'||relname)) AS index_size,
|
||||||
n_live_tup AS row_count,
|
n_live_tup AS row_count,
|
||||||
n_dead_tup AS dead_rows,
|
n_dead_tup AS dead_rows,
|
||||||
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 2) AS dead_pct
|
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 2) AS dead_pct
|
||||||
FROM pg_stat_user_tables
|
FROM pg_stat_user_tables
|
||||||
WHERE schemaname IN ('library', 'users', 'authentication', 'displaypreferences', 'activitylog')
|
WHERE schemaname IN ('library', 'users', 'authentication', 'displaypreferences', 'activitylog')
|
||||||
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
|
ORDER BY pg_total_relation_size(schemaname||'.'||relname) DESC;
|
||||||
"@
|
"@
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -217,6 +232,67 @@ ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Invoke-ConditionalVacuumAnalyze {
|
||||||
|
param(
|
||||||
|
[int]$MinDeadTuples,
|
||||||
|
[double]$MinDeadPercent
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-ColorOutput "`nChecking for tables that need VACUUM ANALYZE..." -Color Cyan
|
||||||
|
Write-ColorOutput "Thresholds: dead tuples >= $MinDeadTuples and dead percent >= $MinDeadPercent" -Color Gray
|
||||||
|
|
||||||
|
$tableQuery = @"
|
||||||
|
SELECT
|
||||||
|
format('%I.%I', schemaname, relname) AS qualified_table,
|
||||||
|
n_dead_tup,
|
||||||
|
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 2) AS dead_pct
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname IN ('library', 'users', 'authentication', 'displaypreferences', 'activitylog')
|
||||||
|
AND n_live_tup > 0
|
||||||
|
AND n_dead_tup >= $MinDeadTuples
|
||||||
|
AND (100.0 * n_dead_tup / NULLIF(n_live_tup, 0)) >= $MinDeadPercent
|
||||||
|
ORDER BY n_dead_tup DESC;
|
||||||
|
"@
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tables = & $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -t -A -F "|" -c $tableQuery 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-ColorOutput "[ERROR] Failed to evaluate dead tuple thresholds: $tables" -Color Red
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$tableLines = @($tables | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||||
|
if ($tableLines.Count -eq 0) {
|
||||||
|
Write-ColorOutput "[OK] No tables exceeded dead tuple thresholds" -Color Green
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-ColorOutput "Found $($tableLines.Count) table(s) requiring VACUUM ANALYZE" -Color Yellow
|
||||||
|
|
||||||
|
foreach ($line in $tableLines) {
|
||||||
|
$parts = $line -split "\|", 3
|
||||||
|
$qualifiedTable = $parts[0].Trim()
|
||||||
|
$deadTuples = if ($parts.Count -ge 2) { $parts[1].Trim() } else { "unknown" }
|
||||||
|
$deadPercent = if ($parts.Count -ge 3) { $parts[2].Trim() } else { "unknown" }
|
||||||
|
|
||||||
|
Write-ColorOutput "Running VACUUM ANALYZE on $qualifiedTable (dead tuples: $deadTuples, dead percent: $deadPercent)" -Color Cyan
|
||||||
|
|
||||||
|
$vacuumResult = & $PSQL_PATH -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -c "VACUUM (ANALYZE, VERBOSE) $qualifiedTable;" 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-ColorOutput "[ERROR] VACUUM ANALYZE failed for $qualifiedTable : $vacuumResult" -Color Red
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-ColorOutput "[OK] VACUUM ANALYZE completed for $qualifiedTable" -Color Green
|
||||||
|
}
|
||||||
|
|
||||||
|
return $true
|
||||||
|
} catch {
|
||||||
|
Write-ColorOutput "[ERROR] Conditional VACUUM ANALYZE failed: $_" -Color Red
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Compare-WithPrevious {
|
function Compare-WithPrevious {
|
||||||
param(
|
param(
|
||||||
[string]$CurrentReport,
|
[string]$CurrentReport,
|
||||||
@@ -361,6 +437,17 @@ Get-IndexUsageStats -OutputFile $indexUsageFile | Out-Null
|
|||||||
$tableSizesFile = Join-Path $reportDir "table_sizes_$timestamp.txt"
|
$tableSizesFile = Join-Path $reportDir "table_sizes_$timestamp.txt"
|
||||||
Get-TableSizeStats -OutputFile $tableSizesFile | Out-Null
|
Get-TableSizeStats -OutputFile $tableSizesFile | Out-Null
|
||||||
|
|
||||||
|
# Optionally vacuum/analyze tables that exceed dead tuple thresholds
|
||||||
|
if ($AutoVacuumAnalyze) {
|
||||||
|
$vacuumSuccess = Invoke-ConditionalVacuumAnalyze `
|
||||||
|
-MinDeadTuples $DeadTupleThreshold `
|
||||||
|
-MinDeadPercent $DeadTuplePercentThreshold
|
||||||
|
|
||||||
|
if (-not $vacuumSuccess) {
|
||||||
|
Write-ColorOutput "[WARN] Conditional VACUUM ANALYZE encountered errors" -Color Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Compare with previous week if requested
|
# Compare with previous week if requested
|
||||||
if ($CompareWithPrevious) {
|
if ($CompareWithPrevious) {
|
||||||
Compare-WithPrevious -CurrentReport $diagnosticsFile -OutputDirectory $reportDir
|
Compare-WithPrevious -CurrentReport $diagnosticsFile -OutputDirectory $reportDir
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
# Database connection settings
|
# Database connection settings
|
||||||
$PSQL_PATH = "C:\Program Files\PostgreSQL\18\bin\psql.exe"
|
$PSQL_PATH = "C:\Program Files\PostgreSQL\18\bin\psql.exe"
|
||||||
$DB_USER = "jellyfin"
|
$DB_USER = "jellyfin"
|
||||||
$DB_NAME = "jellyfin" # ← Change this to switch databases
|
$DB_NAME = "jellyfin_test2" # ← Change this to switch databases
|
||||||
$DB_HOST = "192.168.129.163"
|
$DB_HOST = "192.168.129.253"
|
||||||
$DB_PORT = "5432"
|
$DB_PORT = "6432"
|
||||||
|
|
||||||
# Export for use in other scripts
|
# Export for use in other scripts
|
||||||
$global:PSQL_PATH = $PSQL_PATH
|
$global:PSQL_PATH = $PSQL_PATH
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[4665,7184,10092,15141,27046,37522,49427,71808,83518],{7184:function(a,e,t){"use strict";t(89336),t(36947),t(95021),t(53819),Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(a){return function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=t.width,r=i&&a.matchPatterns[i]||a.matchPatterns[a.defaultMatchWidth],n=e.match(r);if(!n)return null;var l,o=n[0],d=i&&a.parsePatterns[i]||a.parsePatterns[a.defaultParseWidth],u=Array.isArray(d)?function(a){for(var e=0;e<a.length;e++)if(a[e].test(o))return e}(d):function(a){for(var e in a)if(a.hasOwnProperty(e)&&a[e].test(o))return e}(d);return l=a.valueCallback?a.valueCallback(u):u,{value:l=t.valueCallback?t.valueCallback(l):l,rest:e.slice(o.length)}}},a.exports=e.default},10092:function(a,e,t){"use strict";t(36947),t(96054);var i=t(96784).default;Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0;var r=i(t(7184)),n={ordinalNumber:(0,i(t(83518)).default)({matchPattern:/^(\d+)(º)?/i,parsePattern:/\d+/i,valueCallback:function(a){return parseInt(a,10)}}),era:(0,r.default)({matchPatterns:{narrow:/^(aC|dC)/i,abbreviated:/^(a\.?\s?C\.?|a\.?\s?e\.?\s?v\.?|d\.?\s?C\.?|e\.?\s?v\.?)/i,wide:/^(avanti Cristo|avanti Era Volgare|dopo Cristo|Era Volgare)/i},defaultMatchWidth:"wide",parsePatterns:{any:[/^a/i,/^(d|e)/i]},defaultParseWidth:"any"}),quarter:(0,r.default)({matchPatterns:{narrow:/^[1234]/i,abbreviated:/^t[1234]/i,wide:/^[1234](º)? trimestre/i},defaultMatchWidth:"wide",parsePatterns:{any:[/1/i,/2/i,/3/i,/4/i]},defaultParseWidth:"any",valueCallback:function(a){return a+1}}),month:(0,r.default)({matchPatterns:{narrow:/^[gfmalsond]/i,abbreviated:/^(gen|feb|mar|apr|mag|giu|lug|ago|set|ott|nov|dic)/i,wide:/^(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)/i},defaultMatchWidth:"wide",parsePatterns:{narrow:[/^g/i,/^f/i,/^m/i,/^a/i,/^m/i,/^g/i,/^l/i,/^a/i,/^s/i,/^o/i,/^n/i,/^d/i],any:[/^ge/i,/^f/i,/^mar/i,/^ap/i,/^mag/i,/^gi/i,/^l/i,/^ag/i,/^s/i,/^o/i,/^n/i,/^d/i]},defaultParseWidth:"any"}),day:(0,r.default)({matchPatterns:{narrow:/^[dlmgvs]/i,short:/^(do|lu|ma|me|gi|ve|sa)/i,abbreviated:/^(dom|lun|mar|mer|gio|ven|sab)/i,wide:/^(domenica|luned[i|ì]|marted[i|ì]|mercoled[i|ì]|gioved[i|ì]|venerd[i|ì]|sabato)/i},defaultMatchWidth:"wide",parsePatterns:{narrow:[/^d/i,/^l/i,/^m/i,/^m/i,/^g/i,/^v/i,/^s/i],any:[/^d/i,/^l/i,/^ma/i,/^me/i,/^g/i,/^v/i,/^s/i]},defaultParseWidth:"any"}),dayPeriod:(0,r.default)({matchPatterns:{narrow:/^(a|m\.|p|mezzanotte|mezzogiorno|(di|del) (mattina|pomeriggio|sera|notte))/i,any:/^([ap]\.?\s?m\.?|mezzanotte|mezzogiorno|(di|del) (mattina|pomeriggio|sera|notte))/i},defaultMatchWidth:"any",parsePatterns:{any:{am:/^a/i,pm:/^p/i,midnight:/^mezza/i,noon:/^mezzo/i,morning:/mattina/i,afternoon:/pomeriggio/i,evening:/sera/i,night:/notte/i}},defaultParseWidth:"any"})};e.default=n,a.exports=e.default},83518:function(a,e,t){"use strict";t(89336),t(36947),t(95021),t(53819),Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(a){return function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=e.match(a.matchPattern);if(!i)return null;var r=i[0],n=e.match(a.parsePattern);if(!n)return null;var l=a.valueCallback?a.valueCallback(n[0]):n[0];return{value:l=t.valueCallback?t.valueCallback(l):l,rest:e.slice(r.length)}}},a.exports=e.default},96784:function(a){a.exports=function(a){return a&&a.__esModule?a:{default:a}},a.exports.__esModule=!0,a.exports.default=a.exports}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"use strict";(self.webpackChunk=self.webpackChunk||[]).push([[10393],{10393:function(o,n,e){e(36947),e(95021),e(93062),Object.defineProperty(n,"__esModule",{value:!0}),n.default=void 0;var t={lessThanXSeconds:{one:{standalone:"manje od 1 sekunde",withPrepositionAgo:"manje od 1 sekunde",withPrepositionIn:"manje od 1 sekundu"},dual:"manje od {{count}} sekunde",other:"manje od {{count}} sekundi"},xSeconds:{one:{standalone:"1 sekunda",withPrepositionAgo:"1 sekunde",withPrepositionIn:"1 sekundu"},dual:"{{count}} sekunde",other:"{{count}} sekundi"},halfAMinute:"pola minute",lessThanXMinutes:{one:{standalone:"manje od 1 minute",withPrepositionAgo:"manje od 1 minute",withPrepositionIn:"manje od 1 minutu"},dual:"manje od {{count}} minute",other:"manje od {{count}} minuta"},xMinutes:{one:{standalone:"1 minuta",withPrepositionAgo:"1 minute",withPrepositionIn:"1 minutu"},dual:"{{count}} minute",other:"{{count}} minuta"},aboutXHours:{one:{standalone:"oko 1 sat",withPrepositionAgo:"oko 1 sat",withPrepositionIn:"oko 1 sat"},dual:"oko {{count}} sata",other:"oko {{count}} sati"},xHours:{one:{standalone:"1 sat",withPrepositionAgo:"1 sat",withPrepositionIn:"1 sat"},dual:"{{count}} sata",other:"{{count}} sati"},xDays:{one:{standalone:"1 dan",withPrepositionAgo:"1 dan",withPrepositionIn:"1 dan"},dual:"{{count}} dana",other:"{{count}} dana"},aboutXWeeks:{one:{standalone:"oko 1 sedmicu",withPrepositionAgo:"oko 1 sedmicu",withPrepositionIn:"oko 1 sedmicu"},dual:"oko {{count}} sedmice",other:"oko {{count}} sedmice"},xWeeks:{one:{standalone:"1 sedmicu",withPrepositionAgo:"1 sedmicu",withPrepositionIn:"1 sedmicu"},dual:"{{count}} sedmice",other:"{{count}} sedmice"},aboutXMonths:{one:{standalone:"oko 1 mjesec",withPrepositionAgo:"oko 1 mjesec",withPrepositionIn:"oko 1 mjesec"},dual:"oko {{count}} mjeseca",other:"oko {{count}} mjeseci"},xMonths:{one:{standalone:"1 mjesec",withPrepositionAgo:"1 mjesec",withPrepositionIn:"1 mjesec"},dual:"{{count}} mjeseca",other:"{{count}} mjeseci"},aboutXYears:{one:{standalone:"oko 1 godinu",withPrepositionAgo:"oko 1 godinu",withPrepositionIn:"oko 1 godinu"},dual:"oko {{count}} godine",other:"oko {{count}} godina"},xYears:{one:{standalone:"1 godina",withPrepositionAgo:"1 godine",withPrepositionIn:"1 godinu"},dual:"{{count}} godine",other:"{{count}} godina"},overXYears:{one:{standalone:"preko 1 godinu",withPrepositionAgo:"preko 1 godinu",withPrepositionIn:"preko 1 godinu"},dual:"preko {{count}} godine",other:"preko {{count}} godina"},almostXYears:{one:{standalone:"gotovo 1 godinu",withPrepositionAgo:"gotovo 1 godinu",withPrepositionIn:"gotovo 1 godinu"},dual:"gotovo {{count}} godine",other:"gotovo {{count}} godina"}};n.default=function(o,n,e){var i,s=t[o];return i="string"==typeof s?s:1===n?null!=e&&e.addSuffix?e.comparison&&e.comparison>0?s.one.withPrepositionIn:s.one.withPrepositionAgo:s.one.standalone:n%10>1&&n%10<5&&"1"!==String(n).substr(-2,1)?s.dual.replace("{{count}}",String(n)):s.other.replace("{{count}}",String(n)),null!=e&&e.addSuffix?e.comparison&&e.comparison>0?"za "+i:"prije "+i:i},o.exports=n.default}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"use strict";(self.webpackChunk=self.webpackChunk||[]).push([[10455],{10455:function(e,t,o){o(36947),o(95021),o(93062),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n={lessThanXSeconds:{standalone:{one:"vähem kui üks sekund",other:"vähem kui {{count}} sekundit"},withPreposition:{one:"vähem kui ühe sekundi",other:"vähem kui {{count}} sekundi"}},xSeconds:{standalone:{one:"üks sekund",other:"{{count}} sekundit"},withPreposition:{one:"ühe sekundi",other:"{{count}} sekundi"}},halfAMinute:{standalone:"pool minutit",withPreposition:"poole minuti"},lessThanXMinutes:{standalone:{one:"vähem kui üks minut",other:"vähem kui {{count}} minutit"},withPreposition:{one:"vähem kui ühe minuti",other:"vähem kui {{count}} minuti"}},xMinutes:{standalone:{one:"üks minut",other:"{{count}} minutit"},withPreposition:{one:"ühe minuti",other:"{{count}} minuti"}},aboutXHours:{standalone:{one:"umbes üks tund",other:"umbes {{count}} tundi"},withPreposition:{one:"umbes ühe tunni",other:"umbes {{count}} tunni"}},xHours:{standalone:{one:"üks tund",other:"{{count}} tundi"},withPreposition:{one:"ühe tunni",other:"{{count}} tunni"}},xDays:{standalone:{one:"üks päev",other:"{{count}} päeva"},withPreposition:{one:"ühe päeva",other:"{{count}} päeva"}},aboutXWeeks:{standalone:{one:"umbes üks nädal",other:"umbes {{count}} nädalat"},withPreposition:{one:"umbes ühe nädala",other:"umbes {{count}} nädala"}},xWeeks:{standalone:{one:"üks nädal",other:"{{count}} nädalat"},withPreposition:{one:"ühe nädala",other:"{{count}} nädala"}},aboutXMonths:{standalone:{one:"umbes üks kuu",other:"umbes {{count}} kuud"},withPreposition:{one:"umbes ühe kuu",other:"umbes {{count}} kuu"}},xMonths:{standalone:{one:"üks kuu",other:"{{count}} kuud"},withPreposition:{one:"ühe kuu",other:"{{count}} kuu"}},aboutXYears:{standalone:{one:"umbes üks aasta",other:"umbes {{count}} aastat"},withPreposition:{one:"umbes ühe aasta",other:"umbes {{count}} aasta"}},xYears:{standalone:{one:"üks aasta",other:"{{count}} aastat"},withPreposition:{one:"ühe aasta",other:"{{count}} aasta"}},overXYears:{standalone:{one:"rohkem kui üks aasta",other:"rohkem kui {{count}} aastat"},withPreposition:{one:"rohkem kui ühe aasta",other:"rohkem kui {{count}} aasta"}},almostXYears:{standalone:{one:"peaaegu üks aasta",other:"peaaegu {{count}} aastat"},withPreposition:{one:"peaaegu ühe aasta",other:"peaaegu {{count}} aasta"}}};t.default=function(e,t,o){var a,u=null!=o&&o.addSuffix?n[e].withPreposition:n[e].standalone;return a="string"==typeof u?u:1===t?u.one:u.other.replace("{{count}}",String(t)),null!=o&&o.addSuffix?o.comparison&&o.comparison>0?a+" pärast":a+" eest":a},e.exports=t.default}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[1051,4665,7184,15141,27046,37522,49427,71808,83518],{1051:function(t,a,e){"use strict";e(36947),e(96054);var r=e(96784).default;Object.defineProperty(a,"__esModule",{value:!0}),a.default=void 0;var i=r(e(83518)),n=r(e(7184)),l={ordinalNumber:(0,i.default)({matchPattern:/^(\d+)(th|st|nd|rd)?/i,parsePattern:/\d+/i,valueCallback:function(t){return parseInt(t,10)}}),era:(0,n.default)({matchPatterns:{narrow:/[قب]/,abbreviated:/[قب]\.م\./,wide:/(قبل|بعد) الميلاد/},defaultMatchWidth:"wide",parsePatterns:{any:[/قبل/,/بعد/]},defaultParseWidth:"any"}),quarter:(0,n.default)({matchPatterns:{narrow:/^[1234]/i,abbreviated:/ر[1234]/,wide:/الربع (الأول|الثاني|الثالث|الرابع)/},defaultMatchWidth:"wide",parsePatterns:{any:[/1/i,/2/i,/3/i,/4/i]},defaultParseWidth:"any",valueCallback:function(t){return t+1}}),month:(0,n.default)({matchPatterns:{narrow:/^[جفمأسند]/,abbreviated:/^(جانفي|فيفري|مارس|أفريل|ماي|جوان|جويلية|أوت|سبتمبر|أكتوبر|نوفمبر|ديسمبر)/,wide:/^(جانفي|فيفري|مارس|أفريل|ماي|جوان|جويلية|أوت|سبتمبر|أكتوبر|نوفمبر|ديسمبر)/},defaultMatchWidth:"wide",parsePatterns:{narrow:[/^ج/i,/^ف/i,/^م/i,/^أ/i,/^م/i,/^ج/i,/^ج/i,/^أ/i,/^س/i,/^أ/i,/^ن/i,/^د/i],any:[/^جانفي/i,/^فيفري/i,/^مارس/i,/^أفريل/i,/^ماي/i,/^جوان/i,/^جويلية/i,/^أوت/i,/^سبتمبر/i,/^أكتوبر/i,/^نوفمبر/i,/^ديسمبر/i]},defaultParseWidth:"any"}),day:(0,n.default)({matchPatterns:{narrow:/^[حنثرخجس]/i,short:/^(أحد|اثنين|ثلاثاء|أربعاء|خميس|جمعة|سبت)/i,abbreviated:/^(أحد|اثنين|ثلاثاء|أربعاء|خميس|جمعة|سبت)/i,wide:/^(الأحد|الاثنين|الثلاثاء|الأربعاء|الخميس|الجمعة|السبت)/i},defaultMatchWidth:"wide",parsePatterns:{narrow:[/^ح/i,/^ن/i,/^ث/i,/^ر/i,/^خ/i,/^ج/i,/^س/i],wide:[/^الأحد/i,/^الاثنين/i,/^الثلاثاء/i,/^الأربعاء/i,/^الخميس/i,/^الجمعة/i,/^السبت/i],any:[/^أح/i,/^اث/i,/^ث/i,/^أر/i,/^خ/i,/^ج/i,/^س/i]},defaultParseWidth:"any"}),dayPeriod:(0,n.default)({matchPatterns:{narrow:/^(ص|ع|ن ل|ل|(في|مع) (صباح|قايلة|عشية|ليل))/,any:/^([صع]|نص الليل|قايلة|(في|مع) (صباح|قايلة|عشية|ليل))/},defaultMatchWidth:"any",parsePatterns:{any:{am:/^ص/,pm:/^ع/,midnight:/نص الليل/,noon:/قايلة/,afternoon:/بعد القايلة/,morning:/صباح/,evening:/عشية/,night:/ليل/}},defaultParseWidth:"any"})};a.default=l,t.exports=a.default},7184:function(t,a,e){"use strict";e(89336),e(36947),e(95021),e(53819),Object.defineProperty(a,"__esModule",{value:!0}),a.default=function(t){return function(a){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r=e.width,i=r&&t.matchPatterns[r]||t.matchPatterns[t.defaultMatchWidth],n=a.match(i);if(!n)return null;var l,u=n[0],d=r&&t.parsePatterns[r]||t.parsePatterns[t.defaultParseWidth],s=Array.isArray(d)?function(t){for(var a=0;a<t.length;a++)if(t[a].test(u))return a}(d):function(t){for(var a in t)if(t.hasOwnProperty(a)&&t[a].test(u))return a}(d);return l=t.valueCallback?t.valueCallback(s):s,{value:l=e.valueCallback?e.valueCallback(l):l,rest:a.slice(u.length)}}},t.exports=a.default},83518:function(t,a,e){"use strict";e(89336),e(36947),e(95021),e(53819),Object.defineProperty(a,"__esModule",{value:!0}),a.default=function(t){return function(a){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r=a.match(t.matchPattern);if(!r)return null;var i=r[0],n=a.match(t.parsePattern);if(!n)return null;var l=t.valueCallback?t.valueCallback(n[0]):n[0];return{value:l=e.valueCallback?e.valueCallback(l):l,rest:a.slice(i.length)}}},t.exports=a.default},96784:function(t){t.exports=function(t){return t&&t.__esModule?t:{default:t}},t.exports.__esModule=!0,t.exports.default=t.exports}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[9387,10540,19863,31768,42244,75101,94160,97482],{10540:function(e,t,n){"use strict";n(94),n(36947);var a=n(96784).default;Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=a(n(94160)),i={ordinalNumber:function(e,t){var n=Number(e);switch(String(null==t?void 0:t.unit)){case"year":return"".concat(n,"年");case"quarter":return"第".concat(n,"四半期");case"month":return"".concat(n,"月");case"week":return"第".concat(n,"週");case"date":return"".concat(n,"日");case"hour":return"".concat(n,"時");case"minute":return"".concat(n,"分");case"second":return"".concat(n,"秒");default:return"".concat(n)}},era:(0,r.default)({values:{narrow:["BC","AC"],abbreviated:["紀元前","西暦"],wide:["紀元前","西暦"]},defaultWidth:"wide"}),quarter:(0,r.default)({values:{narrow:["1","2","3","4"],abbreviated:["Q1","Q2","Q3","Q4"],wide:["第1四半期","第2四半期","第3四半期","第4四半期"]},defaultWidth:"wide",argumentCallback:function(e){return Number(e)-1}}),month:(0,r.default)({values:{narrow:["1","2","3","4","5","6","7","8","9","10","11","12"],abbreviated:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],wide:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"]},defaultWidth:"wide"}),day:(0,r.default)({values:{narrow:["日","月","火","水","木","金","土"],short:["日","月","火","水","木","金","土"],abbreviated:["日","月","火","水","木","金","土"],wide:["日曜日","月曜日","火曜日","水曜日","木曜日","金曜日","土曜日"]},defaultWidth:"wide"}),dayPeriod:(0,r.default)({values:{narrow:{am:"午前",pm:"午後",midnight:"深夜",noon:"正午",morning:"朝",afternoon:"午後",evening:"夜",night:"深夜"},abbreviated:{am:"午前",pm:"午後",midnight:"深夜",noon:"正午",morning:"朝",afternoon:"午後",evening:"夜",night:"深夜"},wide:{am:"午前",pm:"午後",midnight:"深夜",noon:"正午",morning:"朝",afternoon:"午後",evening:"夜",night:"深夜"}},defaultWidth:"wide",formattingValues:{narrow:{am:"午前",pm:"午後",midnight:"深夜",noon:"正午",morning:"朝",afternoon:"午後",evening:"夜",night:"深夜"},abbreviated:{am:"午前",pm:"午後",midnight:"深夜",noon:"正午",morning:"朝",afternoon:"午後",evening:"夜",night:"深夜"},wide:{am:"午前",pm:"午後",midnight:"深夜",noon:"正午",morning:"朝",afternoon:"午後",evening:"夜",night:"深夜"}},defaultFormattingWidth:"wide"})};t.default=i,e.exports=t.default},94160:function(e,t,n){"use strict";n(44962),n(36947),n(78557),n(82367),Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(e){return function(t,n){var a;if("formatting"===(null!=n&&n.context?String(n.context):"standalone")&&e.formattingValues){var r=e.defaultFormattingWidth||e.defaultWidth,i=null!=n&&n.width?String(n.width):r;a=e.formattingValues[i]||e.formattingValues[r]}else{var o=e.defaultWidth,u=null!=n&&n.width?String(n.width):e.defaultWidth;a=e.values[u]||e.values[o]}return a[e.argumentCallback?e.argumentCallback(t):t]}},e.exports=t.default},96784:function(e){e.exports=function(e){return e&&e.__esModule?e:{default:e}},e.exports.__esModule=!0,e.exports.default=e.exports}}]);
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[10807,33188],{10564:function(e){function t(o){return e.exports=t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},e.exports.__esModule=!0,e.exports.default=e.exports,t(o)}e.exports=t,e.exports.__esModule=!0,e.exports.default=e.exports},33188:function(e,t,o){"use strict";o(36947),o(78557),o(73687);var n=o(96784).default;Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(e){(0,s.default)(1,arguments);var t=Object.prototype.toString.call(e);return e instanceof Date||"object"===(0,r.default)(e)&&"[object Date]"===t?new Date(e.getTime()):"number"==typeof e||"[object Number]"===t?new Date(e):("string"!=typeof e&&"[object String]"!==t||"undefined"==typeof console||(console.warn("Starting with v2.0.0-beta.1 date-fns doesn't accept strings as date arguments. Please use `parseISO` to parse strings. See: https://github.com/date-fns/date-fns/blob/master/docs/upgradeGuide.md#string-arguments"),console.warn((new Error).stack)),new Date(NaN))};var r=n(o(10564)),s=n(o(23058));e.exports=t.default},96784:function(e){e.exports=function(e){return e&&e.__esModule?e:{default:e}},e.exports.__esModule=!0,e.exports.default=e.exports}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[11061,17486,21933,37658,39867,44314,62248,95105],{11061:function(t,e,d){"use strict";d(36947);var u=d(96784).default;Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0;var l=u(d(37658)),a={date:(0,l.default)({formats:{full:"EEEE, y. 'gada' d. MMMM",long:"y. 'gada' d. MMMM",medium:"dd.MM.y.",short:"dd.MM.y."},defaultWidth:"full"}),time:(0,l.default)({formats:{full:"HH:mm:ss zzzz",long:"HH:mm:ss z",medium:"HH:mm:ss",short:"HH:mm"},defaultWidth:"full"}),dateTime:(0,l.default)({formats:{full:"{{date}} 'plkst.' {{time}}",long:"{{date}} 'plkst.' {{time}}",medium:"{{date}}, {{time}}",short:"{{date}}, {{time}}"},defaultWidth:"full"})};e.default=a,t.exports=e.default},37658:function(t,e,d){"use strict";d(36947),Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(t){return function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},d=e.width?String(e.width):t.defaultWidth;return t.formats[d]||t.formats[t.defaultWidth]}},t.exports=e.default},96784:function(t){t.exports=function(t){return t&&t.__esModule?t:{default:t}},t.exports.__esModule=!0,t.exports.default=t.exports}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[1161],{1161:function(t,n,e){"use strict";e.d(n,{Q3:function(){return f}}),e(29305),e(32733),e(84701),e(81678),e(2623),e(24776),e(14382),e(69892),e(84734),e(44962),e(89336),e(4754),e(10849),e(58551),e(70389),e(94),e(33087),e(36947),e(36457),e(88908),e(26437),e(83810),e(52697),e(78557),e(96054),e(90076),e(95021),e(73687),e(83994),e(23630),e(82367);var r=e(63696),u=e(28601);function i(t,n){(null==n||n>t.length)&&(n=t.length);for(var e=0,r=Array(n);e<n;e++)r[e]=t[e];return r}function o(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:500,e=arguments.length>2?arguments[2]:void 0,i=(0,r.useRef)();!function(t){var n=(0,r.useRef)(t);n.current=t,(0,r.useEffect)((function(){return function(){n.current()}}),[])}((function(){i.current&&i.current.cancel()}));var o=(0,r.useMemo)((function(){var r=u(t,n,e),o=function(){return r.apply(void 0,arguments)};return o.cancel=function(){r.cancel()},o.isPending=function(){return!!i.current},o.flush=function(){return r.flush()},o}),[t,n,e]);return(0,r.useEffect)((function(){i.current=u(t,n,e)}),[t,n,e]),o}function f(t,n,e){var u,f,c,a=null!==(u=null==e?void 0:e.equalityFn)&&void 0!==u?u:function(t,n){return t===n},l=t instanceof Function?t():t,s=(f=(0,r.useState)(l),c=2,function(t){if(Array.isArray(t))return t}(f)||function(t,n){var e=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=e){var r,u,i,o,f=[],c=!0,a=!1;try{if(i=(e=e.call(t)).next,0===n){if(Object(e)!==e)return;c=!1}else for(;!(c=(r=i.call(e)).done)&&(f.push(r.value),f.length!==n);c=!0);}catch(t){a=!0,u=t}finally{try{if(!c&&null!=e.return&&(o=e.return(),Object(o)!==o))return}finally{if(a)throw u}}return f}}(f,c)||function(t,n){if(t){if("string"==typeof t)return i(t,n);var e={}.toString.call(t).slice(8,-1);return"Object"===e&&t.constructor&&(e=t.constructor.name),"Map"===e||"Set"===e?Array.from(t):"Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e)?i(t,n):void 0}}(f,c)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()),v=s[0],y=s[1],p=(0,r.useRef)(l),d=o(y,n,e);return a(p.current,l)||(d(l),p.current=l),[v,d]}"undefined"!=typeof window?r.useLayoutEffect:r.useEffect},28601:function(t,n,e){var r=/^\s+|\s+$/g,u=/^[-+]0x[0-9a-f]+$/i,i=/^0b[01]+$/i,o=/^0o[0-7]+$/i,f=parseInt,c="object"==typeof e.g&&e.g&&e.g.Object===Object&&e.g,a="object"==typeof self&&self&&self.Object===Object&&self,l=c||a||Function("return this")(),s=Object.prototype.toString,v=Math.max,y=Math.min,p=function(){return l.Date.now()};function d(t){var n=typeof t;return!!t&&("object"==n||"function"==n)}function b(t){if("number"==typeof t)return t;if(function(t){return"symbol"==typeof t||function(t){return!!t&&"object"==typeof t}(t)&&"[object Symbol]"==s.call(t)}(t))return NaN;if(d(t)){var n="function"==typeof t.valueOf?t.valueOf():t;t=d(n)?n+"":n}if("string"!=typeof t)return 0===t?t:+t;t=t.replace(r,"");var e=i.test(t);return e||o.test(t)?f(t.slice(2),e?2:8):u.test(t)?NaN:+t}t.exports=function(t,n,e){var r,u,i,o,f,c,a=0,l=!1,s=!1,m=!0;if("function"!=typeof t)throw new TypeError("Expected a function");function h(n){var e=r,i=u;return r=u=void 0,a=n,o=t.apply(i,e)}function g(t){var e=t-c;return void 0===c||e>=n||e<0||s&&t-a>=i}function j(){var t=p();if(g(t))return w(t);f=setTimeout(j,function(t){var e=n-(t-c);return s?y(e,i-(t-a)):e}(t))}function w(t){return f=void 0,m&&r?h(t):(r=u=void 0,o)}function O(){var t=p(),e=g(t);if(r=arguments,u=this,c=t,e){if(void 0===f)return function(t){return a=t,f=setTimeout(j,n),l?h(t):o}(c);if(s)return f=setTimeout(j,n),h(c)}return void 0===f&&(f=setTimeout(j,n)),o}return n=b(n)||0,d(e)&&(l=!!e.leading,i=(s="maxWait"in e)?v(b(e.maxWait)||0,n):i,m="trailing"in e?!!e.trailing:m),O.cancel=function(){void 0!==f&&clearTimeout(f),a=0,r=c=u=f=void 0},O.flush=function(){return void 0===f?o:w(p())},O}}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"use strict";(self.webpackChunk=self.webpackChunk||[]).push([[11762],{11762:function(e,n,o){o(36947),o(95021),o(93062),Object.defineProperty(n,"__esModule",{value:!0}),n.default=void 0;var r={lessThanXSeconds:{one:"minder dan een seconde",other:"minder dan {{count}} seconden"},xSeconds:{one:"1 seconde",other:"{{count}} seconden"},halfAMinute:"een halve minuut",lessThanXMinutes:{one:"minder dan een minuut",other:"minder dan {{count}} minuten"},xMinutes:{one:"een minuut",other:"{{count}} minuten"},aboutXHours:{one:"ongeveer 1 uur",other:"ongeveer {{count}} uur"},xHours:{one:"1 uur",other:"{{count}} uur"},xDays:{one:"1 dag",other:"{{count}} dagen"},aboutXWeeks:{one:"ongeveer 1 week",other:"ongeveer {{count}} weken"},xWeeks:{one:"1 week",other:"{{count}} weken"},aboutXMonths:{one:"ongeveer 1 maand",other:"ongeveer {{count}} maanden"},xMonths:{one:"1 maand",other:"{{count}} maanden"},aboutXYears:{one:"ongeveer 1 jaar",other:"ongeveer {{count}} jaar"},xYears:{one:"1 jaar",other:"{{count}} jaar"},overXYears:{one:"meer dan 1 jaar",other:"meer dan {{count}} jaar"},almostXYears:{one:"bijna 1 jaar",other:"bijna {{count}} jaar"}};n.default=function(e,n,o){var t,a=r[e];return t="string"==typeof a?a:1===n?a.one:a.other.replace("{{count}}",String(n)),null!=o&&o.addSuffix?o.comparison&&o.comparison>0?"over "+t:t+" geleden":t},e.exports=n.default}}]);
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
"use strict";(self.webpackChunk=self.webpackChunk||[]).push([[12323],{12323:function(e,t,u){u(36947),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n={lastWeek:function(e){var t=e.getUTCDay(),u="'läschte";return 2!==t&&4!==t||(u+="n"),u+"' eeee 'um' p"},yesterday:"'gëschter um' p",today:"'haut um' p",tomorrow:"'moien um' p",nextWeek:"eeee 'um' p",other:"P"};t.default=function(e,t,u,r){var o=n[e];return"function"==typeof o?o(t):o},e.exports=t.default}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[dir=ltr] .guideProviderSelectListings{margin:0 0 0 .5em}[dir=rtl] .guideProviderSelectListings{margin:0 .5em 0 0}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"use strict";(self.webpackChunk=self.webpackChunk||[]).push([[12394],{12394:function(e,s,u){u.r(s)}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"use strict";(self.webpackChunk=self.webpackChunk||[]).push([[12519],{12519:function(a,t,e){e.r(t),e.d(t,{buildChapterCards:function(){return u}}),e(24776),e(14382),e(78557);var n=e(22696),c=e.n(n),r=e(67430),i=e(98320),d=e(76542),o=e(46782),s=e(34789),l=!s.A.slow&&!s.A.edge;function v(a,t,e,n,r,d,o){var s=r.width,l=r.coverImage,v=function(a,t,e,n,c){var r=a.Id,i=t.ImageTag;return i?c.getScaledImageUrl(r,{maxWidth:n,tag:i,type:"Chapter",index:e}):null}(a,e,n,s||400,t),u="cardContent cardContent-shadow cardImageContainer chapterCardImageContainer";l&&(u+=" coveredImage");var p=' data-action="play" data-isfolder="'.concat(a.IsFolder,'" data-id="').concat(a.Id,'" data-serverid="').concat(a.ServerId,'" data-type="').concat(a.Type,'" data-mediatype="').concat(a.MediaType,'" data-positionticks="').concat(e.StartPositionTicks,'"'),h=v?'<div class="'.concat(u,' lazy" data-src="').concat(v,'">'):'<div class="'.concat(u,'">');v||(h+='<span class="material-icons cardImageIcon local_movies" aria-hidden="true"></span>');var C="";return C+='<div class="cardText">'.concat(c()(e.Name),"</div>"),C+='<div class="cardText">'.concat(i.Ay.getDisplayRunningTime(e.StartPositionTicks),"</div>"),'<button type="button" class="'.concat(d,'"').concat(p,'><div class="').concat("cardBox",'"><div class="').concat("cardScalable",'"><div class="cardPadder-').concat(o,'"></div>').concat(h,'</div><div class="innerCardFooter">').concat(C,"</div></div></div></button>")}function u(a,t,e){if(e.parentContainer){if(!document.body.contains(e.parentContainer))return;if(!t.length)return void e.parentContainer.classList.add("hide");e.parentContainer.classList.remove("hide")}var n=function(a,t,e){var n,c="card itemAction chapterCard";o.A.tv&&(c+=" show-focus",l&&(c+=" show-animation"));var i=((null===(n=(a.MediaSources||[])[0])||void 0===n?void 0:n.MediaStreams)||[]).filter((function(a){return"Video"===a.Type}))[0]||{},d=e.backdropShape||"backdrop";i.Width&&i.Height&&i.Width/i.Height<=1.2&&(d=e.squareShape||"square"),c+=" ".concat(d,"Card"),(e.block||e.rows)&&(c+=" block");for(var s="",u=0,p=r.tF.getApiClient(a.ServerId),h=0,C=t.length;h<C;h++)e.rows&&0===u&&(s+='<div class="cardColumn">'),s+=v(a,p,t[h],h,e,c,d),u++,e.rows&&u>=e.rows&&(u=0,s+="</div>");return s}(a,t,e);e.itemsContainer.innerHTML=n,d.default.lazyChildren(e.itemsContainer)}t.default={buildChapterCards:u}}}]);
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[12557,17486,21933,37658,39867,44314,62248,95105],{12557:function(t,e,d){"use strict";d(36947);var u=d(96784).default;Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0;var l=u(d(37658)),a={date:(0,l.default)({formats:{full:"EEEE, MMMM do, y",long:"MMMM do, y",medium:"MMM d, y",short:"MM/dd/yyyy"},defaultWidth:"full"}),time:(0,l.default)({formats:{full:"h:mm:ss a zzzz",long:"h:mm:ss a z",medium:"h:mm:ss a",short:"h:mm a"},defaultWidth:"full"}),dateTime:(0,l.default)({formats:{full:"{{date}} 'عند' {{time}}",long:"{{date}} 'عند' {{time}}",medium:"{{date}}, {{time}}",short:"{{date}}, {{time}}"},defaultWidth:"full"})};e.default=a,t.exports=e.default},37658:function(t,e,d){"use strict";d(36947),Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(t){return function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},d=e.width?String(e.width):t.defaultWidth;return t.formats[d]||t.formats[t.defaultWidth]}},t.exports=e.default},96784:function(t){t.exports=function(t){return t&&t.__esModule?t:{default:t}},t.exports.__esModule=!0,t.exports.default=t.exports}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[9387,12849,19863,31768,42244,75101,94160,97482],{12849:function(e,t,n){"use strict";n(94),n(36947);var a=n(96784).default;Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var i=a(n(94160)),r={ordinalNumber:function(e){var t=Number(e);return 1===t?t+"-ლი":t+"-ე"},era:(0,i.default)({values:{narrow:["ჩ.წ-მდე","ჩ.წ"],abbreviated:["ჩვ.წ-მდე","ჩვ.წ"],wide:["ჩვენს წელთაღრიცხვამდე","ჩვენი წელთაღრიცხვით"]},defaultWidth:"wide"}),quarter:(0,i.default)({values:{narrow:["1","2","3","4"],abbreviated:["1-ლი კვ","2-ე კვ","3-ე კვ","4-ე კვ"],wide:["1-ლი კვარტალი","2-ე კვარტალი","3-ე კვარტალი","4-ე კვარტალი"]},defaultWidth:"wide",argumentCallback:function(e){return e-1}}),month:(0,i.default)({values:{narrow:["ია","თე","მა","აპ","მს","ვნ","ვლ","აგ","სე","ოქ","ნო","დე"],abbreviated:["იან","თებ","მარ","აპრ","მაი","ივნ","ივლ","აგვ","სექ","ოქტ","ნოე","დეკ"],wide:["იანვარი","თებერვალი","მარტი","აპრილი","მაისი","ივნისი","ივლისი","აგვისტო","სექტემბერი","ოქტომბერი","ნოემბერი","დეკემბერი"]},defaultWidth:"wide"}),day:(0,i.default)({values:{narrow:["კვ","ორ","სა","ოთ","ხუ","პა","შა"],short:["კვი","ორშ","სამ","ოთხ","ხუთ","პარ","შაბ"],abbreviated:["კვი","ორშ","სამ","ოთხ","ხუთ","პარ","შაბ"],wide:["კვირა","ორშაბათი","სამშაბათი","ოთხშაბათი","ხუთშაბათი","პარასკევი","შაბათი"]},defaultWidth:"wide"}),dayPeriod:(0,i.default)({values:{narrow:{am:"a",pm:"p",midnight:"შუაღამე",noon:"შუადღე",morning:"დილა",afternoon:"საღამო",evening:"საღამო",night:"ღამე"},abbreviated:{am:"AM",pm:"PM",midnight:"შუაღამე",noon:"შუადღე",morning:"დილა",afternoon:"საღამო",evening:"საღამო",night:"ღამე"},wide:{am:"a.m.",pm:"p.m.",midnight:"შუაღამე",noon:"შუადღე",morning:"დილა",afternoon:"საღამო",evening:"საღამო",night:"ღამე"}},defaultWidth:"wide",formattingValues:{narrow:{am:"a",pm:"p",midnight:"შუაღამით",noon:"შუადღისას",morning:"დილით",afternoon:"ნაშუადღევს",evening:"საღამოს",night:"ღამით"},abbreviated:{am:"AM",pm:"PM",midnight:"შუაღამით",noon:"შუადღისას",morning:"დილით",afternoon:"ნაშუადღევს",evening:"საღამოს",night:"ღამით"},wide:{am:"a.m.",pm:"p.m.",midnight:"შუაღამით",noon:"შუადღისას",morning:"დილით",afternoon:"ნაშუადღევს",evening:"საღამოს",night:"ღამით"}},defaultFormattingWidth:"wide"})};t.default=r,e.exports=t.default},94160:function(e,t,n){"use strict";n(44962),n(36947),n(78557),n(82367),Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(e){return function(t,n){var a;if("formatting"===(null!=n&&n.context?String(n.context):"standalone")&&e.formattingValues){var i=e.defaultFormattingWidth||e.defaultWidth,r=null!=n&&n.width?String(n.width):i;a=e.formattingValues[r]||e.formattingValues[i]}else{var d=e.defaultWidth,o=null!=n&&n.width?String(n.width):e.defaultWidth;a=e.values[o]||e.values[d]}return a[e.argumentCallback?e.argumentCallback(t):t]}},e.exports=t.default},96784:function(e){e.exports=function(e){return e&&e.__esModule?e:{default:e}},e.exports.__esModule=!0,e.exports.default=e.exports}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[1286,17486,21933,37658,39867,44314,62248,95105],{1286:function(t,e,d){"use strict";d(36947);var u=d(96784).default;Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0;var l=u(d(37658)),f={date:(0,l.default)({formats:{full:"EEEE d MMMM y",long:"d MMMM y",medium:"d MMM y",short:"y-MM-dd"},defaultWidth:"full"}),time:(0,l.default)({formats:{full:"'kl'. HH:mm:ss zzzz",long:"HH:mm:ss z",medium:"HH:mm:ss",short:"HH:mm"},defaultWidth:"full"}),dateTime:(0,l.default)({formats:{full:"{{date}} 'kl.' {{time}}",long:"{{date}} 'kl.' {{time}}",medium:"{{date}} {{time}}",short:"{{date}} {{time}}"},defaultWidth:"full"})};e.default=f,t.exports=e.default},37658:function(t,e,d){"use strict";d(36947),Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(t){return function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},d=e.width?String(e.width):t.defaultWidth;return t.formats[d]||t.formats[t.defaultWidth]}},t.exports=e.default},96784:function(t){t.exports=function(t){return t&&t.__esModule?t:{default:t}},t.exports.__esModule=!0,t.exports.default=t.exports}}]);
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[13135,17486,21933,37658,39867,44314,62248,95105],{13135:function(t,e,d){"use strict";d(36947);var u=d(96784).default;Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0;var l=u(d(37658)),a={date:(0,l.default)({formats:{full:"y'年'M'月'd'日' EEEE",long:"y'年'M'月'd'日'",medium:"yyyy-MM-dd",short:"yy-MM-dd"},defaultWidth:"full"}),time:(0,l.default)({formats:{full:"zzzz a h:mm:ss",long:"z a h:mm:ss",medium:"a h:mm:ss",short:"a h:mm"},defaultWidth:"full"}),dateTime:(0,l.default)({formats:{full:"{{date}} {{time}}",long:"{{date}} {{time}}",medium:"{{date}} {{time}}",short:"{{date}} {{time}}"},defaultWidth:"full"})};e.default=a,t.exports=e.default},37658:function(t,e,d){"use strict";d(36947),Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(t){return function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},d=e.width?String(e.width):t.defaultWidth;return t.formats[d]||t.formats[t.defaultWidth]}},t.exports=e.default},96784:function(t){t.exports=function(t){return t&&t.__esModule?t:{default:t}},t.exports.__esModule=!0,t.exports.default=t.exports}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"use strict";(self.webpackChunk=self.webpackChunk||[]).push([[13231],{13231:function(u,e,t){t(36947),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0;var r={lastWeek:function(u){switch(u.getUTCDay()){case 0:return"'prošlu nedjelju u' p";case 3:return"'prošlu srijedu u' p";case 6:return"'prošlu subotu u' p";default:return"'prošli' EEEE 'u' p"}},yesterday:"'jučer u' p",today:"'danas u' p",tomorrow:"'sutra u' p",nextWeek:function(u){switch(u.getUTCDay()){case 0:return"'iduću nedjelju u' p";case 3:return"'iduću srijedu u' p";case 6:return"'iduću subotu u' p";default:return"'prošli' EEEE 'u' p"}},other:"P"};e.default=function(u,e,t,n){var a=r[u];return"function"==typeof a?a(e):a},u.exports=e.default}}]);
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
.mediaInfoItem{padding:0}[dir=ltr] .mediaInfoItem{margin:0 1em 0 0}[dir=rtl] .mediaInfoItem{margin:0 0 0 1em}.mediaInfoText{-webkit-align-items:center;align-items:center;border-radius:.25em;display:-webkit-flex;display:flex;font-size:92%;margin:0 .5em 0 0;padding:.22em .5em;white-space:nowrap}.mediaInfoText-upper{text-transform:uppercase}.mediaInfoIconItem{font-size:1.6em;height:auto;margin-right:.6em;width:auto}[dir=ltr] .mediaInfoItem:last-child{margin-right:0}[dir=rtl] .mediaInfoItem:last-child{margin-left:0}.starRatingContainer{-webkit-align-items:center;align-items:center;display:-webkit-flex;display:flex;-webkit-justify-content:center;justify-content:center;vertical-align:middle}.starIcon{color:#f2b01e;font-size:1.4em;height:auto!important;margin-right:.125em;width:auto!important}.mediaInfoCriticRating{-webkit-align-items:center;align-items:center;background-repeat:no-repeat;background-size:auto 1.2em;display:-webkit-flex;display:flex;min-height:1.2em}[dir=ltr] .mediaInfoCriticRating{background-position:0;padding-left:1.5em}[dir=rtl] .mediaInfoCriticRating{background-position:100%;padding-right:1.5em}.mediaInfoCriticRatingFresh{background-image:url(assets/img/fresh.svg)}.mediaInfoCriticRatingRotten{background-image:url(assets/img/rotten.svg)}.mediaInfoProgramAttribute{border-radius:.15em;font-size:80%;padding:.16em .6em;text-transform:uppercase}.closedCaptionMediaInfoText{font-weight:700}
|
||||||
|
.btnFilterWithIndicator{position:relative}.filterIndicator{-webkit-align-items:center;align-items:center;background:#03a9f4;border-radius:100em;box-shadow:0 4px 5px 0 rgba(0,0,0,.14),0 1px 10px 0 rgba(0,0,0,.12),0 2px 4px -1px rgba(0,0,0,.2);color:#fff;display:-webkit-flex;display:flex;font-size:60%;font-weight:700;height:1.8em;-webkit-justify-content:center;justify-content:center;position:absolute;right:2px;top:2px;width:1.8em;z-index:1}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[9387,13356,19863,31768,42244,75101,94160,97482],{13356:function(e,t,n){"use strict";n(94),n(36947);var a=n(96784).default;Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var i=a(n(94160)),o={ordinalNumber:function(e,t){var n=Number(e);return String(n)},era:(0,i.default)({values:{narrow:["aC","dC"],abbreviated:["a.C.","d.C."],wide:["avanti Cristo","dopo Cristo"]},defaultWidth:"wide"}),quarter:(0,i.default)({values:{narrow:["1","2","3","4"],abbreviated:["T1","T2","T3","T4"],wide:["1º trimestre","2º trimestre","3º trimestre","4º trimestre"]},defaultWidth:"wide",argumentCallback:function(e){return e-1}}),month:(0,i.default)({values:{narrow:["G","F","M","A","M","G","L","A","S","O","N","D"],abbreviated:["gen","feb","mar","apr","mag","giu","lug","ago","set","ott","nov","dic"],wide:["gennaio","febbraio","marzo","aprile","maggio","giugno","luglio","agosto","settembre","ottobre","novembre","dicembre"]},defaultWidth:"wide"}),day:(0,i.default)({values:{narrow:["D","L","M","M","G","V","S"],short:["dom","lun","mar","mer","gio","ven","sab"],abbreviated:["dom","lun","mar","mer","gio","ven","sab"],wide:["domenica","lunedì","martedì","mercoledì","giovedì","venerdì","sabato"]},defaultWidth:"wide"}),dayPeriod:(0,i.default)({values:{narrow:{am:"m.",pm:"p.",midnight:"mezzanotte",noon:"mezzogiorno",morning:"mattina",afternoon:"pomeriggio",evening:"sera",night:"notte"},abbreviated:{am:"AM",pm:"PM",midnight:"mezzanotte",noon:"mezzogiorno",morning:"mattina",afternoon:"pomeriggio",evening:"sera",night:"notte"},wide:{am:"AM",pm:"PM",midnight:"mezzanotte",noon:"mezzogiorno",morning:"mattina",afternoon:"pomeriggio",evening:"sera",night:"notte"}},defaultWidth:"wide",formattingValues:{narrow:{am:"m.",pm:"p.",midnight:"mezzanotte",noon:"mezzogiorno",morning:"di mattina",afternoon:"del pomeriggio",evening:"di sera",night:"di notte"},abbreviated:{am:"AM",pm:"PM",midnight:"mezzanotte",noon:"mezzogiorno",morning:"di mattina",afternoon:"del pomeriggio",evening:"di sera",night:"di notte"},wide:{am:"AM",pm:"PM",midnight:"mezzanotte",noon:"mezzogiorno",morning:"di mattina",afternoon:"del pomeriggio",evening:"di sera",night:"di notte"}},defaultFormattingWidth:"wide"})};t.default=o,e.exports=t.default},94160:function(e,t,n){"use strict";n(44962),n(36947),n(78557),n(82367),Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(e){return function(t,n){var a;if("formatting"===(null!=n&&n.context?String(n.context):"standalone")&&e.formattingValues){var i=e.defaultFormattingWidth||e.defaultWidth,o=null!=n&&n.width?String(n.width):i;a=e.formattingValues[o]||e.formattingValues[i]}else{var r=e.defaultWidth,d=null!=n&&n.width?String(n.width):e.defaultWidth;a=e.values[d]||e.values[r]}return a[e.argumentCallback?e.argumentCallback(t):t]}},e.exports=t.default},96784:function(e){e.exports=function(e){return e&&e.__esModule?e:{default:e}},e.exports.__esModule=!0,e.exports.default=e.exports}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[1367,17486,21933,37658,39867,44314,62248,95105],{1367:function(t,e,d){"use strict";d(36947);var u=d(96784).default;Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0;var l=u(d(37658)),f={date:(0,l.default)({formats:{full:"EEEE, do MMMM y",long:"do MMMM y",medium:"do MMM y",short:"dd.MM.y"},defaultWidth:"full"}),time:(0,l.default)({formats:{full:"HH:mm:ss zzzz",long:"HH:mm:ss z",medium:"HH:mm:ss",short:"HH:mm"},defaultWidth:"full"}),dateTime:(0,l.default)({formats:{full:"{{date}} {{time}}",long:"{{date}} {{time}}",medium:"{{date}}, {{time}}",short:"{{date}}, {{time}}"},defaultWidth:"full"})};e.default=f,t.exports=e.default},37658:function(t,e,d){"use strict";d(36947),Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(t){return function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},d=e.width?String(e.width):t.defaultWidth;return t.formats[d]||t.formats[t.defaultWidth]}},t.exports=e.default},96784:function(t){t.exports=function(t){return t&&t.__esModule?t:{default:t}},t.exports.__esModule=!0,t.exports.default=t.exports}}]);
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
"use strict";(self.webpackChunk=self.webpackChunk||[]).push([[13734],{13734:function(n,e,o){o(36947),o(95021),o(93062),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0;var t={lessThanXSeconds:{one:"mwens pase yon segond",other:"mwens pase {{count}} segond"},xSeconds:{one:"1 segond",other:"{{count}} segond"},halfAMinute:"30 segond",lessThanXMinutes:{one:"mwens pase yon minit",other:"mwens pase {{count}} minit"},xMinutes:{one:"1 minit",other:"{{count}} minit"},aboutXHours:{one:"anviwon inè",other:"anviwon {{count}} è"},xHours:{one:"1 lè",other:"{{count}} lè"},xDays:{one:"1 jou",other:"{{count}} jou"},aboutXWeeks:{one:"anviwon 1 semèn",other:"anviwon {{count}} semèn"},xWeeks:{one:"1 semèn",other:"{{count}} semèn"},aboutXMonths:{one:"anviwon 1 mwa",other:"anviwon {{count}} mwa"},xMonths:{one:"1 mwa",other:"{{count}} mwa"},aboutXYears:{one:"anviwon 1 an",other:"anviwon {{count}} an"},xYears:{one:"1 an",other:"{{count}} an"},overXYears:{one:"plis pase 1 an",other:"plis pase {{count}} an"},almostXYears:{one:"prèske 1 an",other:"prèske {{count}} an"}};e.default=function(n,e,o){var s,a=t[n];return s="string"==typeof a?a:1===e?a.one:a.other.replace("{{count}}",String(e)),null!=o&&o.addSuffix?o.comparison&&o.comparison>0?"nan "+s:"sa fè "+s:s},n.exports=e.default}}]);
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[10807,13960,33188],{10564:function(e){function t(o){return e.exports=t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},e.exports.__esModule=!0,e.exports.default=e.exports,t(o)}e.exports=t,e.exports.__esModule=!0,e.exports.default=e.exports},13141:function(e,t,o){"use strict";o(36947);var r=o(96784).default;Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var n=r(o(56974));function u(e,t,o){var r="eeee p";return(0,n.default)(e,t,o)?r:e.getTime()>t.getTime()?"'下个'"+r:"'上个'"+r}var s={lastWeek:u,yesterday:"'昨天' p",today:"'今天' p",tomorrow:"'明天' p",nextWeek:u,other:"PP p"};t.default=function(e,t,o,r){var n=s[e];return"function"==typeof n?n(t,o,r):n},e.exports=t.default},33188:function(e,t,o){"use strict";o(36947),o(78557),o(73687);var r=o(96784).default;Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(e){(0,u.default)(1,arguments);var t=Object.prototype.toString.call(e);return e instanceof Date||"object"===(0,n.default)(e)&&"[object Date]"===t?new Date(e.getTime()):"number"==typeof e||"[object Number]"===t?new Date(e):("string"!=typeof e&&"[object String]"!==t||"undefined"==typeof console||(console.warn("Starting with v2.0.0-beta.1 date-fns doesn't accept strings as date arguments. Please use `parseISO` to parse strings. See: https://github.com/date-fns/date-fns/blob/master/docs/upgradeGuide.md#string-arguments"),console.warn((new Error).stack)),new Date(NaN))};var n=r(o(10564)),u=r(o(23058));e.exports=t.default},96784:function(e){e.exports=function(e){return e&&e.__esModule?e:{default:e}},e.exports.__esModule=!0,e.exports.default=e.exports}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk=self.webpackChunk||[]).push([[14112,17486,21933,37658,39867,44314,62248,95105],{14112:function(t,e,d){"use strict";d(36947);var u=d(96784).default;Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0;var a=u(d(37658)),l={date:(0,a.default)({formats:{full:"EEEE, MMMM do, y",long:"MMMM do, y",medium:"MMM d, y",short:"MM/dd/yyyy"},defaultWidth:"full"}),time:(0,a.default)({formats:{full:"h:mm:ss a zzzz",long:"h:mm:ss a z",medium:"h:mm:ss a",short:"h:mm a"},defaultWidth:"full"}),dateTime:(0,a.default)({formats:{full:"{{date}} 'aig' {{time}}",long:"{{date}} 'aig' {{time}}",medium:"{{date}}, {{time}}",short:"{{date}}, {{time}}"},defaultWidth:"full"})};e.default=l,t.exports=e.default},37658:function(t,e,d){"use strict";d(36947),Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(t){return function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},d=e.width?String(e.width):t.defaultWidth;return t.formats[d]||t.formats[t.defaultWidth]}},t.exports=e.default},96784:function(t){t.exports=function(t){return t&&t.__esModule?t:{default:t}},t.exports.__esModule=!0,t.exports.default=t.exports}}]);
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
|||||||
|
@-webkit-keyframes fadein{0%{opacity:0}to{opacity:1}}@keyframes fadein{0%{opacity:0}to{opacity:1}}.lazy-image-fadein{-webkit-animation:fadein .5s;animation:fadein .5s;opacity:1}.lazy-image-fadein-fast{-webkit-animation:fadein .1s;animation:fadein .1s;opacity:1}.lazy-hidden,.lazy-hidden-children *{opacity:0}.blurhash-canvas{bottom:0;height:100%;left:0;pointer-events:none;position:absolute;right:0;top:0;width:100%}
|
||||||
|
.actionSheet{border:none;border-radius:.1em!important;display:-webkit-flex;display:flex;-webkit-justify-content:center;justify-content:center;max-height:84%;padding:0}.actionsheet-not-fullscreen{max-height:90%;max-width:90%}.actionsheet-fullscreen{border-radius:0!important;max-height:none}.actionSheetContent-centered{-webkit-align-items:center;align-items:center;text-align:center}.actionSheetContent{display:-webkit-flex;display:flex;-webkit-flex-direction:column;flex-direction:column;-webkit-flex-grow:1;flex-grow:1;-webkit-justify-content:center;justify-content:center;margin:0!important;overflow:hidden;padding:.4em 0!important}.actionSheetMenuItem{border-radius:0;box-shadow:none;-webkit-flex-shrink:0;flex-shrink:0;font-weight:inherit;margin:0}.actionSheetMenuItem:focus{-webkit-transform:none!important;transform:none!important}[dir=ltr] .actionsheetListItemBody{padding:.4em 1em .4em .6em!important}[dir=rtl] .actionsheetListItemBody{padding:.4em .6em .4em 1em!important}.actionSheetItemText{display:-webkit-flex;display:flex;-webkit-flex-grow:1;flex-grow:1;-webkit-justify-content:flex-start;justify-content:flex-start;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;white-space:nowrap}.actionSheetItemAsideText{display:-webkit-flex;display:flex;-webkit-flex-shrink:0;flex-shrink:0;font-size:90%;-webkit-justify-content:flex-end;justify-content:flex-end;opacity:.7}[dir=ltr] .actionSheetItemAsideText{margin-left:5em;margin-right:.5em}[dir=rtl] .actionSheetItemAsideText{margin-left:.5em;margin-right:5em}.actionSheetScroller{display:-webkit-flex;display:flex;-webkit-flex-direction:column;flex-direction:column;margin-bottom:0!important;width:100%}.actionSheetScroller-tv{max-height:64%;max-width:60%;width:auto}.actionsheetDivider{-webkit-flex-shrink:0;flex-shrink:0;height:.07em;margin:.25em 0}.actionSheetTitle{margin:.6em 0 .7em!important}.actionSheetText,.actionSheetTitle{-webkit-flex-grow:0;flex-grow:0;padding:0 .75rem}.actionSheetText{margin-top:0}.actionsheetMenuItemIcon{padding:0!important}[dir=ltr] .actionsheetMenuItemIcon{margin:0 .85em 0 .45em!important}[dir=rtl] .actionsheetMenuItemIcon{margin:0 .45em 0 .85em!important}.actionsheet-xlargeFont{font-size:112%!important}.btnCloseActionSheet{position:fixed;top:.75em}[dir=ltr] .btnCloseActionSheet{left:.5em}[dir=rtl] .btnCloseActionSheet{right:.5em}
|
||||||
|
.appfooter{bottom:0;contain:layout style;left:0;padding-bottom:env(safe-area-inset-bottom);padding-left:env(safe-area-inset-left);padding-right:env(safe-area-inset-right);position:fixed;right:0;transition:-webkit-transform .18s linear;transition:transform .18s linear;transition:transform .18s linear,-webkit-transform .18s linear;z-index:1201!important}.appfooter:empty{padding:0}.appfooter.headroom--unpinned{-webkit-transform:translateY(100%)!important;transform:translateY(100%)!important}
|
||||||
|
.nowPlayingBarInfoContainer{-webkit-align-items:center;align-items:center;display:-webkit-flex;display:flex;height:100%;overflow:hidden;width:40%}.nowPlayingBar{contain:layout style;cursor:pointer;text-align:center;transition:-webkit-transform .2s ease-out;transition:transform .2s ease-out;transition:transform .2s ease-out,-webkit-transform .2s ease-out;will-change:transform}.nowPlayingBar-hidden{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}.nowPlayingBarTop{-webkit-align-items:center;align-items:center;display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;height:4.2em;-webkit-justify-content:center;justify-content:center;position:relative}.mediaButton,.nowPlayingBarUserDataButtons .btnUserItemRating{margin:0;text-align:center;vertical-align:middle}.mediaButton{font-size:120%}.nowPlayingBar .nowPlayingImage{background-position:50%;background-repeat:no-repeat;background-size:contain;-webkit-flex-shrink:0;flex-shrink:0;height:70%;width:4.2em}.nowPlayingBarText{-webkit-flex-grow:1;flex-grow:1;font-size:92%;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;white-space:nowrap}[dir=ltr] .nowPlayingBarText{margin-left:.5em;margin-right:1em;text-align:left}[dir=rtl] .nowPlayingBarText{margin-left:1em;margin-right:.5em;text-align:right}.nowPlayingBarCenter{-webkit-align-items:center;align-items:center;display:-webkit-flex;display:flex;-webkit-flex-grow:1;flex-grow:1;-webkit-justify-content:center;justify-content:center;position:absolute;text-align:center;vertical-align:middle;z-index:2}.nowPlayingBarPositionContainer{left:0;position:absolute!important;right:0;top:-.56em;z-index:1}.headroom--unpinned .nowPlayingBarPositionContainer,.noMediaProgress .nowPlayingBarPositionContainer{display:none}.nowPlayingBarRight{-webkit-align-items:center;align-items:center;display:-webkit-flex;display:flex;-webkit-flex-shrink:0;flex-shrink:0;-webkit-justify-content:flex-end;justify-content:flex-end;position:relative;z-index:2}[dir=ltr] .nowPlayingBarRight{margin:0 .5em 0 auto}[dir=rtl] .nowPlayingBarRight{margin:0 auto 0 .5em}.nowPlayingBarCurrentTime{display:inline-block;padding-left:1.5em;text-align:center;vertical-align:middle}[dir=ltr] .nowPlayingBarVolumeSliderContainer{margin-right:2em}[dir=rtl] .nowPlayingBarVolumeSliderContainer{margin-left:2em}.nowPlayingBarUserDataButtons{display:inline-block}.nowPlayingBarPositionSlider::-webkit-slider-thumb{height:1.2em!important;width:1.2em!important}@media (max-width:70em){.nowPlayingBarRight .nowPlayingBarUserDataButtons{display:none}}@media (max-width:66em){.nowPlayingBar .btnShuffleQueue,.toggleRepeatButton{display:none!important}}@media (max-width:80em){.nowPlayingBarCenter .nowPlayingBarCurrentTime,.nowPlayingBarCenter .stopButton{display:none!important}.nowPlayingBarInfoContainer{width:45%}}.layout-desktop .nowPlayingBarRight .playPauseButton,.layout-mobile .nowPlayingBarRight button:not(.playPauseButton):not(.nextTrackButton),.layout-mobile .nowPlayingBarRight div,.layout-mobile .nowPlayingBarRight input,.layout-tv .nowPlayingBarRight .playPauseButton{display:none}@media (max-width:56em){.nowPlayingBarCenter{display:none!important}}@media (max-width:60em){.nowPlayingBarRight .nowPlayingBarVolumeSliderContainer{display:none!important}.nowPlayingBarInfoContainer{width:100%}}@media (max-width:24em){.nowPlayingBar .muteButton,.nowPlayingBar .unmuteButton{display:none}}
|
||||||
|
.mdl-slider{-webkit-align-self:center;align-self:center;-webkit-appearance:none;appearance:none;background:transparent;color:#00a4dc;cursor:pointer;height:150%;margin:0;outline:0;-webkit-user-select:none;-ms-user-select:none;user-select:none;width:100%;z-index:1;-webkit-tap-highlight-color:rgba(0,0,0,0);display:block;font-size:inherit}.mdl-slider::-moz-focus-outer{border:0}.mdl-slider::-ms-tooltip{display:none}.mdl-slider::-webkit-slider-runnable-track{background:transparent}.mdl-slider::-ms-track{background:none;border:none;color:transparent;height:.2em;width:100%}.mdl-slider::-ms-fill-lower,.mdl-slider::-ms-fill-upper{display:none}.slider-browser-edge{margin-left:-.16em;margin-right:-.16em;width:150%}.mdl-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;background:#00a4dc;border:none;border-radius:50%;box-sizing:border-box;height:1.08em;pointer-events:none;-webkit-transition:.2s;transition:.2s;width:1.08em}.mdl-slider-hoverthumb:hover::-webkit-slider-thumb,.mdl-slider.show-focus:focus::-webkit-slider-thumb{-webkit-transform:scale(1.3);transform:scale(1.3)}.mdl-slider::-moz-range-thumb{appearance:none;background:#00a4dc;background-image:none;border:none;border-radius:50%;box-sizing:border-box;height:1.08em;-moz-transition:.2s;transition:.2s;width:1.08em}.mdl-slider-hoverthumb:hover::-moz-range-thumb,.mdl-slider.show-focus:focus::-moz-range-thumb{transform:scale(1.3)}.mdl-slider::-ms-thumb{appearance:none;background:#00a4dc;border:none;border-radius:50%;box-sizing:border-box;height:1.4em;transform:scale(.771429);-ms-transition:.2s;transition:.2s;width:1.4em}.mdl-slider-hoverthumb:hover::-ms-thumb,.mdl-slider.show-focus:focus::-ms-thumb{transform:none}.mdl-slider[disabled]::-webkit-slider-thumb{display:none}.mdl-slider[disabled]::-moz-range-thumb{display:none}.mdl-slider[disabled]::-ms-thumb{display:none}.mdl-slider-container{background:none;display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;height:1.25em;position:relative}.mdl-slider-background-flex-container{box-sizing:border-box;padding:0 .54em;position:absolute;top:50%;width:100%}[dir=ltr] .mdl-slider-background-flex-container{left:0}[dir=rtl] .mdl-slider-background-flex-container{right:0}.mdl-slider-background-flex{background:hsla(0,0%,100%,.3);border:0;display:-webkit-flex;display:flex;height:.2em;margin-top:-.1em;overflow:hidden;padding:0;top:50%;width:100%}[dir=ltr] .mdl-slider-background-flex{left:0}[dir=rtl] .mdl-slider-background-flex{right:0}.mdl-slider-background-flex-inner{position:relative;width:100%}.mdl-slider-background-lower{background-color:#00a4dc;bottom:0;position:absolute;top:0;width:0}[dir=ltr] .mdl-slider-background-lower{left:0}[dir=rtl] .mdl-slider-background-lower{right:0}.mdl-slider-background-lower-clear{background-color:transparent}.mdl-slider-background-lower-withtransform{-webkit-transform:scaleX(0);transform:scaleX(0);-webkit-transform-origin:left center;transform-origin:left center;width:100%}.mdl-slider-background-upper{background:#666;background:hsla(0,0%,100%,.4);bottom:0;left:0;position:absolute;top:0;width:0}.sliderBubbleTrack{left:0;margin:0 .54em;position:absolute;right:0}.sliderBubble{-webkit-align-items:center;align-items:center;background:#282828;color:#fff;display:-webkit-flex;display:flex;-webkit-justify-content:center;justify-content:center;left:0;position:absolute;top:0;-webkit-transform:translate3d(-50%,-120%,0);transform:translate3d(-50%,-120%,0);z-index:1}.sliderBubbleText{margin:0;padding:.5em .75em}.sliderContainer-settings{margin-bottom:1.8em;position:relative}.sliderContainer-settings .mdl-slider-container{height:2.83em}.sliderLabel{display:block;margin-bottom:.25em}.sliderMarkerContainer{left:0;margin:0 .54em;position:absolute;right:0}.sliderMarker{height:12px;position:absolute;-webkit-transform:translate3d(0,25%,0);transform:translate3d(0,25%,0);width:2px}.sliderMarker.unwatched{background-color:hsla(0,0%,100%,.3)}.sliderMarker.watched{background-color:#00a4dc}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
|||||||
|
.actionSheet{border:none;border-radius:.1em!important;display:-webkit-flex;display:flex;-webkit-justify-content:center;justify-content:center;max-height:84%;padding:0}.actionsheet-not-fullscreen{max-height:90%;max-width:90%}.actionsheet-fullscreen{border-radius:0!important;max-height:none}.actionSheetContent-centered{-webkit-align-items:center;align-items:center;text-align:center}.actionSheetContent{display:-webkit-flex;display:flex;-webkit-flex-direction:column;flex-direction:column;-webkit-flex-grow:1;flex-grow:1;-webkit-justify-content:center;justify-content:center;margin:0!important;overflow:hidden;padding:.4em 0!important}.actionSheetMenuItem{border-radius:0;box-shadow:none;-webkit-flex-shrink:0;flex-shrink:0;font-weight:inherit;margin:0}.actionSheetMenuItem:focus{-webkit-transform:none!important;transform:none!important}[dir=ltr] .actionsheetListItemBody{padding:.4em 1em .4em .6em!important}[dir=rtl] .actionsheetListItemBody{padding:.4em .6em .4em 1em!important}.actionSheetItemText{display:-webkit-flex;display:flex;-webkit-flex-grow:1;flex-grow:1;-webkit-justify-content:flex-start;justify-content:flex-start;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;white-space:nowrap}.actionSheetItemAsideText{display:-webkit-flex;display:flex;-webkit-flex-shrink:0;flex-shrink:0;font-size:90%;-webkit-justify-content:flex-end;justify-content:flex-end;opacity:.7}[dir=ltr] .actionSheetItemAsideText{margin-left:5em;margin-right:.5em}[dir=rtl] .actionSheetItemAsideText{margin-left:.5em;margin-right:5em}.actionSheetScroller{display:-webkit-flex;display:flex;-webkit-flex-direction:column;flex-direction:column;margin-bottom:0!important;width:100%}.actionSheetScroller-tv{max-height:64%;max-width:60%;width:auto}.actionsheetDivider{-webkit-flex-shrink:0;flex-shrink:0;height:.07em;margin:.25em 0}.actionSheetTitle{margin:.6em 0 .7em!important}.actionSheetText,.actionSheetTitle{-webkit-flex-grow:0;flex-grow:0;padding:0 .75rem}.actionSheetText{margin-top:0}.actionsheetMenuItemIcon{padding:0!important}[dir=ltr] .actionsheetMenuItemIcon{margin:0 .85em 0 .45em!important}[dir=rtl] .actionsheetMenuItemIcon{margin:0 .45em 0 .85em!important}.actionsheet-xlargeFont{font-size:112%!important}.btnCloseActionSheet{position:fixed;top:.75em}[dir=ltr] .btnCloseActionSheet{left:.5em}[dir=rtl] .btnCloseActionSheet{right:.5em}
|
||||||
|
.emby-select{box-sizing:border-box;display:block;font-family:inherit;font-size:110%;font-weight:inherit;margin:0;margin-bottom:0!important;outline:none!important;-webkit-tap-highlight-color:rgba(0,0,0,0);width:100%}[dir=ltr] .emby-select{padding:.5em 1.9em .5em .5em}[dir=rtl] .emby-select{padding:.5em .5em .5em 1.9em}.emby-select[disabled]{-webkit-appearance:none;appearance:none;background:none!important;border-color:transparent!important;color:inherit!important}.emby-select::-moz-focus-inner{border:0}.selectContainer-inline>.emby-select{font-size:inherit}[dir=ltr] .selectContainer-inline>.emby-select{padding:.3em 1.9em .3em .5em}[dir=rtl] .selectContainer-inline>.emby-select{padding:.3em .5em .3em 1.9em}.selectContainer-inline>.emby-select[disabled]{padding-left:0;padding-right:0}.emby-select-focusscale{-webkit-transform-origin:center center;transform-origin:center center;transition:-webkit-transform .18s ease-out!important;transition:transform .18s ease-out!important;transition:transform .18s ease-out,-webkit-transform .18s ease-out!important}.emby-select-focusscale:focus{-webkit-transform:scale(1.04);transform:scale(1.04);z-index:1}.emby-select+.fieldDescription{margin-top:.25em}.selectContainer{margin-bottom:1.8em;position:relative}.selectContainer-inline{-webkit-align-items:center;align-items:center;display:-webkit-inline-flex;display:inline-flex;margin-bottom:0}.selectLabel{display:block;margin-bottom:.25em}.selectContainer-inline>.selectLabel{-webkit-flex-shrink:0;flex-shrink:0;margin-bottom:0;margin-right:.5em}.trackSelections>.selectContainer{margin:.4em 0}.emby-select-withcolor{-webkit-appearance:none;appearance:none;border-radius:.2em}.selectArrowContainer{color:inherit;pointer-events:none;position:absolute;top:.2em}[dir=ltr] .selectArrowContainer{right:.3em}[dir=rtl] .selectArrowContainer{left:.3em}.selectContainer-inline>.selectArrowContainer{bottom:.24em;font-size:90%;top:auto}.emby-select[disabled]+.selectArrowContainer{display:none}.selectArrow{font-size:1.7em;margin-top:1.2em}.emby-select-iconbutton{-webkit-align-self:flex-end;align-self:flex-end}
|
||||||
|
[dir=ltr] .guideProviderSelectListings{margin:0 0 0 .5em}[dir=rtl] .guideProviderSelectListings{margin:0 .5em 0 0}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"use strict";(self.webpackChunk=self.webpackChunk||[]).push([[14553],{14553:function(e,t,r){r(36947),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var u={lastWeek:"eeee 'dernier à' p",yesterday:"'hier à' p",today:"'aujourd’hui à' p",tomorrow:"'demain à' p'",nextWeek:"eeee 'prochain à' p",other:"P"};t.default=function(e,t,r,a){return u[e]},e.exports=t.default}}]);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user