Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77e30685bb |
-16
@@ -7,11 +7,7 @@
|
|||||||
[Rr]elease/
|
[Rr]elease/
|
||||||
[Bb]in/
|
[Bb]in/
|
||||||
[Oo]bj/
|
[Oo]bj/
|
||||||
.idea/
|
|
||||||
*.lscache
|
|
||||||
.github/
|
|
||||||
|
|
||||||
.github
|
|
||||||
/.vs/Jellyfin
|
/.vs/Jellyfin
|
||||||
/.vs
|
/.vs
|
||||||
**/obj/
|
**/obj/
|
||||||
@@ -24,7 +20,6 @@
|
|||||||
*.pdb
|
*.pdb
|
||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
*.lscache
|
|
||||||
|
|
||||||
# Centralized lib output folder
|
# Centralized lib output folder
|
||||||
/lib/
|
/lib/
|
||||||
@@ -38,14 +33,3 @@ wwwroot/*
|
|||||||
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
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||||
|
</project>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<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">
|
||||||
@@ -50,10 +49,9 @@
|
|||||||
<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.670" />
|
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
||||||
<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" />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
<WarningsAsErrors></WarningsAsErrors>
|
<WarningsAsErrors></WarningsAsErrors>
|
||||||
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
||||||
<NoWarn>$(NoWarn);IDE0065;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CA1865;CA1869;CA1716;CA2101;SA1127;CA1845;CA1859;CA2007</NoWarn>
|
<NoWarn>$(NoWarn);IDE0065</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
|
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
|
||||||
@@ -32,10 +32,6 @@
|
|||||||
<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" />
|
||||||
@@ -66,7 +62,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<NoWarn>$(NoWarn);NETSDK1057;CA1848;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CA1865;CA1869;CA1716;CA2101;SA1127;CA1845;CA1859;CA2007</NoWarn>
|
<NoWarn>$(NoWarn);NETSDK1057</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<!-- 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>
|
||||||
@@ -16,7 +15,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
|
||||||
<PackageReference Include="TagLibSharp" />
|
<PackageReference Include="TagLibSharp" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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;
|
||||||
@@ -307,32 +306,6 @@ 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,8 +6,6 @@ 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>
|
||||||
@@ -56,54 +54,10 @@ 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;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ using System.Linq;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Emby.Naming.Common;
|
using Emby.Naming.Common;
|
||||||
using Emby.Photos;
|
using Emby.Photos;
|
||||||
@@ -409,7 +410,7 @@ namespace Emby.Server.Implementations
|
|||||||
/// Runs the startup tasks.
|
/// Runs the startup tasks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns><see cref="Task" />.</returns>
|
/// <returns><see cref="Task" />.</returns>
|
||||||
public Task RunStartupTasksAsync()
|
public async Task RunStartupTasksAsync()
|
||||||
{
|
{
|
||||||
Logger.LogInformation("Running startup tasks");
|
Logger.LogInformation("Running startup tasks");
|
||||||
|
|
||||||
@@ -426,10 +427,64 @@ namespace Emby.Server.Implementations
|
|||||||
}
|
}
|
||||||
|
|
||||||
Logger.LogInformation("ServerId: {ServerId}", SystemId);
|
Logger.LogInformation("ServerId: {ServerId}", SystemId);
|
||||||
|
|
||||||
|
// Register this instance in multi-instance mode
|
||||||
|
await RegisterInstanceAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Start cache coordinator for multi-instance cache invalidation
|
||||||
|
await StartCacheCoordinatorAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
Logger.LogInformation("Core startup complete");
|
Logger.LogInformation("Core startup complete");
|
||||||
CoreStartupHasCompleted = true;
|
CoreStartupHasCompleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
private async Task RegisterInstanceAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var instanceRegistry = Resolve<Jellyfin.Server.Implementations.Clustering.IInstanceRegistry>();
|
||||||
|
var networkConfig = ConfigurationManager.GetNetworkConfiguration();
|
||||||
|
|
||||||
|
var hostname = Environment.MachineName;
|
||||||
|
var processId = Environment.ProcessId;
|
||||||
|
var httpPort = networkConfig.InternalHttpPort;
|
||||||
|
var httpsPort = networkConfig.InternalHttpsPort > 0 ? networkConfig.InternalHttpsPort : (int?)null;
|
||||||
|
var version = ApplicationVersionString;
|
||||||
|
|
||||||
|
await instanceRegistry.RegisterInstanceAsync(
|
||||||
|
hostname,
|
||||||
|
processId,
|
||||||
|
httpPort,
|
||||||
|
httpsPort,
|
||||||
|
version,
|
||||||
|
capabilities: null,
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Instance registered: {InstanceId} on {Hostname}:{HttpPort}",
|
||||||
|
instanceRegistry.CurrentInstanceId,
|
||||||
|
hostname,
|
||||||
|
httpPort);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to register instance in database");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartCacheCoordinatorAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cacheCoordinator = Resolve<Jellyfin.Server.Implementations.Clustering.ICacheCoordinator>();
|
||||||
|
await cacheCoordinator.StartAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Logger.LogInformation("Cache coordinator started for multi-instance cache synchronization");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to start cache coordinator");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -492,7 +547,16 @@ namespace Emby.Server.Implementations
|
|||||||
|
|
||||||
serviceCollection.AddSingleton(NetManager);
|
serviceCollection.AddSingleton(NetManager);
|
||||||
|
|
||||||
serviceCollection.AddSingleton<ITaskManager, TaskManager>();
|
// Register the actual TaskManager
|
||||||
|
serviceCollection.AddSingleton<TaskManager>();
|
||||||
|
// Wrap it with primary instance checking decorator
|
||||||
|
serviceCollection.AddSingleton<ITaskManager>(provider =>
|
||||||
|
{
|
||||||
|
var taskManager = provider.GetRequiredService<TaskManager>();
|
||||||
|
var primaryElection = provider.GetRequiredService<Jellyfin.Server.Implementations.Clustering.IPrimaryElectionService>();
|
||||||
|
var logger = provider.GetRequiredService<ILogger<Jellyfin.Server.Implementations.Clustering.PrimaryInstanceTaskManager>>();
|
||||||
|
return new Jellyfin.Server.Implementations.Clustering.PrimaryInstanceTaskManager(taskManager, primaryElection, logger);
|
||||||
|
});
|
||||||
|
|
||||||
serviceCollection.AddSingleton(_xmlSerializer);
|
serviceCollection.AddSingleton(_xmlSerializer);
|
||||||
|
|
||||||
@@ -517,7 +581,6 @@ 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>();
|
||||||
@@ -555,6 +618,16 @@ namespace Emby.Server.Implementations
|
|||||||
serviceCollection.AddTransient(provider => new Lazy<ILiveTvManager>(provider.GetRequiredService<ILiveTvManager>));
|
serviceCollection.AddTransient(provider => new Lazy<ILiveTvManager>(provider.GetRequiredService<ILiveTvManager>));
|
||||||
serviceCollection.AddSingleton<IDtoService, DtoService>();
|
serviceCollection.AddSingleton<IDtoService, DtoService>();
|
||||||
|
|
||||||
|
// Multi-instance support services
|
||||||
|
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.IInstanceRegistry, Jellyfin.Server.Implementations.Clustering.InstanceRegistry>();
|
||||||
|
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.IDistributedLockManager, Jellyfin.Server.Implementations.Clustering.DistributedLockManager>();
|
||||||
|
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.IPostgresNotificationListener, Jellyfin.Server.Implementations.Clustering.PostgresNotificationListener>();
|
||||||
|
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.ICacheCoordinator, Jellyfin.Server.Implementations.Clustering.CacheCoordinator>();
|
||||||
|
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.IPrimaryElectionService, Jellyfin.Server.Implementations.Clustering.PrimaryElectionService>();
|
||||||
|
serviceCollection.AddHostedService(provider => (Jellyfin.Server.Implementations.Clustering.PrimaryElectionService)provider.GetRequiredService<Jellyfin.Server.Implementations.Clustering.IPrimaryElectionService>());
|
||||||
|
serviceCollection.AddSingleton<Jellyfin.Server.Implementations.Clustering.IFileSystemChangeProcessor, Jellyfin.Server.Implementations.Clustering.FileSystemChangeProcessor>();
|
||||||
|
serviceCollection.AddHostedService(provider => (Jellyfin.Server.Implementations.Clustering.FileSystemChangeProcessor)provider.GetRequiredService<Jellyfin.Server.Implementations.Clustering.IFileSystemChangeProcessor>());
|
||||||
|
|
||||||
serviceCollection.AddSingleton<ISessionManager, SessionManager>();
|
serviceCollection.AddSingleton<ISessionManager, SessionManager>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<ICollectionManager, CollectionManager>();
|
serviceCollection.AddSingleton<ICollectionManager, CollectionManager>();
|
||||||
@@ -601,37 +674,11 @@ 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))
|
||||||
@@ -685,7 +732,6 @@ 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,6 +18,7 @@ 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,15 +108,14 @@ 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))
|
||||||
{
|
{
|
||||||
// Use the execution strategy to handle transactional consistency and retries
|
var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
await using (transaction.ConfigureAwait(false))
|
||||||
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,7 +2,6 @@
|
|||||||
|
|
||||||
<!-- 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>
|
||||||
|
|
||||||
@@ -25,11 +24,11 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BitFaster.Caching" />
|
<PackageReference Include="BitFaster.Caching" />
|
||||||
<PackageReference Include="DiscUtils.Udf" />
|
<PackageReference Include="DiscUtils.Udf" />
|
||||||
|
|
||||||
<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>
|
||||||
@@ -39,14 +38,12 @@
|
|||||||
</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,15 +22,6 @@ 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
|
||||||
{
|
{
|
||||||
@@ -56,7 +47,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 containing authenticated user/device details.</param>
|
/// <param name="authorizationInfo">The authorization information.</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,25 +18,6 @@ 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,26 +1223,29 @@ 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;
|
||||||
|
|
||||||
if (numTasks == 0)
|
foreach (var task in tasks)
|
||||||
{
|
{
|
||||||
_itemRepository.UpdateInheritedValues();
|
// Prevent access to modified closure
|
||||||
progress.Report(100);
|
var currentNumComplete = numComplete;
|
||||||
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 =>
|
||||||
{
|
{
|
||||||
progressValues[taskIndex] = pct;
|
double innerPercent = pct;
|
||||||
progress.Report(progressValues.Average());
|
innerPercent /= 100;
|
||||||
|
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);
|
||||||
@@ -1257,12 +1260,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
_logger.LogError(ex, "Error running post-scan task");
|
_logger.LogError(ex, "Error running post-scan task");
|
||||||
}
|
}
|
||||||
|
|
||||||
progressValues[taskIndex] = 100;
|
numComplete++;
|
||||||
progress.Report(progressValues.Average());
|
double percent = numComplete;
|
||||||
|
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);
|
||||||
@@ -1994,7 +1997,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.SaveItemsAsync(items, cancellationToken).GetAwaiter().GetResult();
|
_itemRepository.SaveItems(items, cancellationToken);
|
||||||
|
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
@@ -2162,7 +2165,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
item.DateLastSaved = DateTime.UtcNow;
|
item.DateLastSaved = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _itemRepository.SaveItemsAsync(items, cancellationToken).ConfigureAwait(false);
|
_itemRepository.SaveItems(items, cancellationToken);
|
||||||
|
|
||||||
if (parent is Folder folder)
|
if (parent is Folder folder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
// <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,28 +62,24 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var keys = item.GetUserDataKeys();
|
var keys = item.GetUserDataKeys();
|
||||||
|
|
||||||
using var dbContext = _repository.CreateDbContext();
|
using var dbContext = _repository.CreateDbContext();
|
||||||
var executionStrategy = dbContext.Database.CreateExecutionStrategy();
|
using var transaction = dbContext.Database.BeginTransaction();
|
||||||
executionStrategy.Execute(() =>
|
|
||||||
|
foreach (var key in keys)
|
||||||
{
|
{
|
||||||
using var transaction = dbContext.Database.BeginTransaction();
|
userData.Key = key;
|
||||||
|
var userDataEntry = Map(userData, user.Id, item.Id);
|
||||||
foreach (var key in keys)
|
if (dbContext.UserData.Any(f => f.ItemId == userDataEntry.ItemId && f.UserId == userDataEntry.UserId && f.CustomDataKey == userDataEntry.CustomDataKey))
|
||||||
{
|
{
|
||||||
userData.Key = key;
|
dbContext.UserData.Attach(userDataEntry).State = EntityState.Modified;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dbContext.UserData.Add(userDataEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dbContext.SaveChanges();
|
dbContext.SaveChanges();
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
});
|
|
||||||
|
|
||||||
var userId = user.InternalId;
|
var userId = user.InternalId;
|
||||||
var cacheKey = GetCacheKey(userId, item.Id);
|
var cacheKey = GetCacheKey(userId, item.Id);
|
||||||
|
|||||||
@@ -145,11 +145,6 @@ 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 =>
|
||||||
{
|
{
|
||||||
@@ -163,7 +158,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
return index == -1 ? int.MaxValue : index;
|
return index == -1 ? int.MaxValue : index;
|
||||||
})
|
})
|
||||||
.ThenBy(i => sortedIndexLookup.TryGetValue(i.Id, out var sortIndex) ? sortIndex : int.MaxValue)
|
.ThenBy(sorted.IndexOf)
|
||||||
.ThenBy(i => i.SortName)
|
.ThenBy(i => i.SortName)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
private readonly IMediaSourceManager _mediaSourceManager;
|
private readonly IMediaSourceManager _mediaSourceManager;
|
||||||
private readonly IServerApplicationHost _appHost;
|
private readonly IServerApplicationHost _appHost;
|
||||||
private readonly IDeviceManager _deviceManager;
|
private readonly IDeviceManager _deviceManager;
|
||||||
|
private readonly Jellyfin.Server.Implementations.Clustering.IInstanceRegistry _instanceRegistry;
|
||||||
private readonly CancellationTokenRegistration _shutdownCallback;
|
private readonly CancellationTokenRegistration _shutdownCallback;
|
||||||
private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections
|
private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections
|
||||||
= new(StringComparer.OrdinalIgnoreCase);
|
= new(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -93,6 +94,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
/// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
|
/// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
|
||||||
/// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
|
/// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
|
||||||
/// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param>
|
/// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param>
|
||||||
|
/// <param name="instanceRegistry">Instance registry for multi-instance support (optional for backward compatibility).</param>
|
||||||
public SessionManager(
|
public SessionManager(
|
||||||
ILogger<SessionManager> logger,
|
ILogger<SessionManager> logger,
|
||||||
IEventManager eventManager,
|
IEventManager eventManager,
|
||||||
@@ -106,7 +108,8 @@ namespace Emby.Server.Implementations.Session
|
|||||||
IServerApplicationHost appHost,
|
IServerApplicationHost appHost,
|
||||||
IDeviceManager deviceManager,
|
IDeviceManager deviceManager,
|
||||||
IMediaSourceManager mediaSourceManager,
|
IMediaSourceManager mediaSourceManager,
|
||||||
IHostApplicationLifetime hostApplicationLifetime)
|
IHostApplicationLifetime hostApplicationLifetime,
|
||||||
|
Jellyfin.Server.Implementations.Clustering.IInstanceRegistry instanceRegistry = null)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_eventManager = eventManager;
|
_eventManager = eventManager;
|
||||||
@@ -120,6 +123,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
_appHost = appHost;
|
_appHost = appHost;
|
||||||
_deviceManager = deviceManager;
|
_deviceManager = deviceManager;
|
||||||
_mediaSourceManager = mediaSourceManager;
|
_mediaSourceManager = mediaSourceManager;
|
||||||
|
_instanceRegistry = instanceRegistry;
|
||||||
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
|
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
|
||||||
|
|
||||||
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
|
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
|
||||||
@@ -156,10 +160,30 @@ namespace Emby.Server.Implementations.Session
|
|||||||
public event EventHandler<SessionEventArgs> SessionControllerConnected;
|
public event EventHandler<SessionEventArgs> SessionControllerConnected;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all connections.
|
/// Gets all connections for the current instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>All connections.</value>
|
/// <value>All connections.</value>
|
||||||
public IEnumerable<SessionInfo> Sessions => _activeConnections.Values.OrderByDescending(c => c.LastActivityDate);
|
/// <remarks>
|
||||||
|
/// In multi-instance deployments, only returns sessions owned by the current instance.
|
||||||
|
/// In single-instance deployments (when _instanceRegistry is null), returns all sessions.
|
||||||
|
/// </remarks>
|
||||||
|
public IEnumerable<SessionInfo> Sessions
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_instanceRegistry is null)
|
||||||
|
{
|
||||||
|
// Single-instance mode: return all sessions
|
||||||
|
return _activeConnections.Values.OrderByDescending(c => c.LastActivityDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-instance mode: only return sessions for this instance
|
||||||
|
var currentInstanceId = _instanceRegistry.CurrentInstanceId;
|
||||||
|
return _activeConnections.Values
|
||||||
|
.Where(s => s.InstanceId.Equals(currentInstanceId))
|
||||||
|
.OrderByDescending(c => c.LastActivityDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDeviceManagerDeviceOptionsUpdated(object sender, GenericEventArgs<Tuple<string, DeviceOptions>> e)
|
private void OnDeviceManagerDeviceOptionsUpdated(object sender, GenericEventArgs<Tuple<string, DeviceOptions>> e)
|
||||||
{
|
{
|
||||||
@@ -556,7 +580,8 @@ namespace Emby.Server.Implementations.Session
|
|||||||
DeviceId = deviceId,
|
DeviceId = deviceId,
|
||||||
ApplicationVersion = appVersion,
|
ApplicationVersion = appVersion,
|
||||||
Id = key.GetMD5().ToString("N", CultureInfo.InvariantCulture),
|
Id = key.GetMD5().ToString("N", CultureInfo.InvariantCulture),
|
||||||
ServerId = _appHost.SystemId
|
ServerId = _appHost.SystemId,
|
||||||
|
InstanceId = _instanceRegistry?.CurrentInstanceId ?? Guid.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
var username = user?.Username;
|
var username = user?.Username;
|
||||||
|
|||||||
@@ -1,302 +0,0 @@
|
|||||||
// <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,12 +2,10 @@
|
|||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -16,7 +14,6 @@
|
|||||||
<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,22 +83,11 @@ public class ExceptionMiddleware
|
|||||||
{
|
{
|
||||||
if (isAuthenticationError)
|
if (isAuthenticationError)
|
||||||
{
|
{
|
||||||
// WebSocket authentication failures are expected when clients connect without tokens
|
// Log authentication errors as warnings with user-friendly message
|
||||||
// Log at Debug level to reduce noise, unless it's not a WebSocket endpoint
|
_logger.LogWarning(
|
||||||
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase))
|
"Access denied: Unable to process request. Authentication token missing or invalid. URL {Method} {Url}.",
|
||||||
{
|
context.Request.Method,
|
||||||
_logger.LogDebug(
|
context.Request.Path);
|
||||||
"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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
// <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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
// <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,7 +1,6 @@
|
|||||||
<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>
|
||||||
@@ -12,13 +11,11 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
// <copyright file="CacheCoordinator.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coordinates cache invalidation across multiple Jellyfin instances using PostgreSQL LISTEN/NOTIFY.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CacheCoordinator : ICacheCoordinator, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private const string CacheInvalidationChannel = "jellyfin_cache_invalidation";
|
||||||
|
|
||||||
|
private readonly IPostgresNotificationListener _notificationListener;
|
||||||
|
private readonly IInstanceRegistry _instanceRegistry;
|
||||||
|
private readonly ILogger<CacheCoordinator> _logger;
|
||||||
|
private readonly ILibraryManager? _libraryManager;
|
||||||
|
private bool _disposed;
|
||||||
|
private bool _started;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CacheCoordinator"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="notificationListener">The PostgreSQL notification listener.</param>
|
||||||
|
/// <param name="instanceRegistry">The instance registry.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="libraryManager">The library manager (optional).</param>
|
||||||
|
public CacheCoordinator(
|
||||||
|
IPostgresNotificationListener notificationListener,
|
||||||
|
IInstanceRegistry instanceRegistry,
|
||||||
|
ILogger<CacheCoordinator> logger,
|
||||||
|
ILibraryManager? libraryManager = null)
|
||||||
|
{
|
||||||
|
_notificationListener = notificationListener;
|
||||||
|
_instanceRegistry = instanceRegistry;
|
||||||
|
_logger = logger;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_started)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Cache coordinator already started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_notificationListener.NotificationReceived += OnNotificationReceived;
|
||||||
|
await _notificationListener.StartListeningAsync(CacheInvalidationChannel, cancellationToken).ConfigureAwait(false);
|
||||||
|
_started = true;
|
||||||
|
|
||||||
|
_logger.LogInformation("Cache coordinator started successfully");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to start cache coordinator");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_started)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_notificationListener.NotificationReceived -= OnNotificationReceived;
|
||||||
|
await _notificationListener.StopListeningAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
_started = false;
|
||||||
|
|
||||||
|
_logger.LogInformation("Cache coordinator stopped");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error stopping cache coordinator");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task InvalidateItemAsync(Guid itemId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var message = new CacheInvalidationMessage
|
||||||
|
{
|
||||||
|
Type = CacheInvalidationType.Item,
|
||||||
|
EntityId = itemId,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
SourceInstanceId = _instanceRegistry.CurrentInstanceId
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task InvalidateUserDataAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var message = new CacheInvalidationMessage
|
||||||
|
{
|
||||||
|
Type = CacheInvalidationType.UserData,
|
||||||
|
EntityId = userId,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
SourceInstanceId = _instanceRegistry.CurrentInstanceId
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task InvalidateImageAsync(Guid itemId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var message = new CacheInvalidationMessage
|
||||||
|
{
|
||||||
|
Type = CacheInvalidationType.Image,
|
||||||
|
EntityId = itemId,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
SourceInstanceId = _instanceRegistry.CurrentInstanceId
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task InvalidateAllAsync(CacheInvalidationType cacheType, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var message = new CacheInvalidationMessage
|
||||||
|
{
|
||||||
|
Type = cacheType,
|
||||||
|
EntityId = null,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
SourceInstanceId = _instanceRegistry.CurrentInstanceId
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task InvalidateLibraryAsync(Guid libraryId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var message = new CacheInvalidationMessage
|
||||||
|
{
|
||||||
|
Type = CacheInvalidationType.Library,
|
||||||
|
EntityId = libraryId,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
SourceInstanceId = _instanceRegistry.CurrentInstanceId
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task InvalidatePersonAsync(Guid personId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var message = new CacheInvalidationMessage
|
||||||
|
{
|
||||||
|
Type = CacheInvalidationType.Person,
|
||||||
|
EntityId = personId,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
SourceInstanceId = _instanceRegistry.CurrentInstanceId
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task InvalidateMetadataAsync(Guid itemId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var message = new CacheInvalidationMessage
|
||||||
|
{
|
||||||
|
Type = CacheInvalidationType.Metadata,
|
||||||
|
EntityId = itemId,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
SourceInstanceId = _instanceRegistry.CurrentInstanceId
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
await StopAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendInvalidationMessageAsync(CacheInvalidationMessage message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(message);
|
||||||
|
await _notificationListener.NotifyAsync(CacheInvalidationChannel, json, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Sent cache invalidation: Type={Type}, EntityId={EntityId}",
|
||||||
|
message.Type,
|
||||||
|
message.EntityId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to send cache invalidation message: Type={Type}, EntityId={EntityId}",
|
||||||
|
message.Type,
|
||||||
|
message.EntityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNotificationReceived(object? sender, PostgresNotificationEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var message = JsonSerializer.Deserialize<CacheInvalidationMessage>(e.Payload);
|
||||||
|
|
||||||
|
if (message is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Received invalid cache invalidation message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't process messages from this instance
|
||||||
|
if (message.SourceInstanceId.Equals(_instanceRegistry.CurrentInstanceId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Processing cache invalidation from instance {SourceInstanceId}: Type={Type}, EntityId={EntityId}",
|
||||||
|
message.SourceInstanceId,
|
||||||
|
message.Type,
|
||||||
|
message.EntityId);
|
||||||
|
|
||||||
|
ProcessInvalidationMessage(message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing cache invalidation notification");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessInvalidationMessage(CacheInvalidationMessage message)
|
||||||
|
{
|
||||||
|
// TODO: Actual cache invalidation logic would go here
|
||||||
|
// This is a placeholder that demonstrates the pattern
|
||||||
|
// The actual implementation would call into Jellyfin's cache managers
|
||||||
|
|
||||||
|
switch (message.Type)
|
||||||
|
{
|
||||||
|
case CacheInvalidationType.Item:
|
||||||
|
if (message.EntityId.HasValue && _libraryManager is not null)
|
||||||
|
{
|
||||||
|
// Example: _libraryManager.InvalidateItemCache(message.EntityId.Value);
|
||||||
|
_logger.LogDebug("Invalidating item cache for {ItemId}", message.EntityId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CacheInvalidationType.UserData:
|
||||||
|
if (message.EntityId.HasValue)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Invalidating user data cache for {UserId}", message.EntityId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CacheInvalidationType.Image:
|
||||||
|
if (message.EntityId.HasValue)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Invalidating image cache for {ItemId}", message.EntityId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CacheInvalidationType.Library:
|
||||||
|
if (message.EntityId.HasValue)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Invalidating library cache for {LibraryId}", message.EntityId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CacheInvalidationType.All:
|
||||||
|
_logger.LogDebug("Invalidating all caches of type {CacheType}", message.Type);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
_logger.LogWarning("Unknown cache invalidation type: {Type}", message.Type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// <copyright file="CacheInvalidationMessage.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Types of cache invalidation.
|
||||||
|
/// </summary>
|
||||||
|
public enum CacheInvalidationType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate a specific item cache.
|
||||||
|
/// </summary>
|
||||||
|
Item,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate user data cache.
|
||||||
|
/// </summary>
|
||||||
|
UserData,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate image cache.
|
||||||
|
/// </summary>
|
||||||
|
Image,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate chapter image cache.
|
||||||
|
/// </summary>
|
||||||
|
ChapterImage,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate metadata cache.
|
||||||
|
/// </summary>
|
||||||
|
Metadata,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate all caches of a specific type.
|
||||||
|
/// </summary>
|
||||||
|
All,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate library cache.
|
||||||
|
/// </summary>
|
||||||
|
Library,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate person cache.
|
||||||
|
/// </summary>
|
||||||
|
Person,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate user cache.
|
||||||
|
/// </summary>
|
||||||
|
User,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate device cache.
|
||||||
|
/// </summary>
|
||||||
|
Device
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a cache invalidation message sent between instances.
|
||||||
|
/// </summary>
|
||||||
|
public class CacheInvalidationMessage
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of cache being invalidated.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public CacheInvalidationType Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ID of the entity being invalidated (if applicable).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("entityId")]
|
||||||
|
public Guid? EntityId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the cache key pattern to invalidate.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("cacheKey")]
|
||||||
|
public string? CacheKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the timestamp when this message was created.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("timestamp")]
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the instance ID that originated this message.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("sourceInstanceId")]
|
||||||
|
public Guid SourceInstanceId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets additional metadata about the invalidation.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("metadata")]
|
||||||
|
public string? Metadata { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
// <copyright file="DistributedLockManager.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of distributed lock manager using database-backed locks.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DistributedLockManager : IDistributedLockManager, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||||
|
private readonly ILogger<DistributedLockManager> _logger;
|
||||||
|
private readonly Guid _instanceId;
|
||||||
|
private readonly ConcurrentDictionary<string, DistributedLockHandle> _heldLocks;
|
||||||
|
private readonly TimeSpan _defaultLockDuration;
|
||||||
|
private readonly Timer? _cleanupTimer;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DistributedLockManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dbContextFactory">The database context factory.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="instanceId">The current instance ID.</param>
|
||||||
|
public DistributedLockManager(
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||||
|
ILogger<DistributedLockManager> logger,
|
||||||
|
Guid instanceId)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_logger = logger;
|
||||||
|
_instanceId = instanceId;
|
||||||
|
_heldLocks = new ConcurrentDictionary<string, DistributedLockHandle>();
|
||||||
|
_defaultLockDuration = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
// Start cleanup timer - runs every minute
|
||||||
|
_cleanupTimer = new Timer(
|
||||||
|
CleanupTimerCallback,
|
||||||
|
null,
|
||||||
|
TimeSpan.FromMinutes(1),
|
||||||
|
TimeSpan.FromMinutes(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IDistributedLockHandle?> TryAcquireLockAsync(
|
||||||
|
string lockName,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(lockName);
|
||||||
|
|
||||||
|
var startTime = DateTime.UtcNow;
|
||||||
|
var endTime = startTime.Add(timeout);
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < endTime)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var handle = await AcquireLockInternalAsync(lockName, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (handle is not null)
|
||||||
|
{
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error attempting to acquire lock '{LockName}'", lockName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit before retrying
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Failed to acquire lock '{LockName}' within timeout {Timeout}", lockName, timeout);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IDistributedLockHandle> AcquireLockAsync(
|
||||||
|
string lockName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(lockName);
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var handle = await AcquireLockInternalAsync(lockName, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (handle is not null)
|
||||||
|
{
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error attempting to acquire lock '{LockName}'", lockName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before retrying
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new OperationCanceledException("Lock acquisition was cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> IsLockHeldAsync(string lockName, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(lockName);
|
||||||
|
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var lockEntity = await context.DistributedLocks
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(l => l.LockName == lockName, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (lockEntity is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if lock is expired
|
||||||
|
if (lockEntity.IsExpired())
|
||||||
|
{
|
||||||
|
// Expired locks are considered not held
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ReleaseAllLocksAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Releasing all locks held by instance {InstanceId}", _instanceId);
|
||||||
|
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var locks = await context.DistributedLocks
|
||||||
|
.Where(l => l.InstanceId.Equals(_instanceId))
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (locks.Count > 0)
|
||||||
|
{
|
||||||
|
context.DistributedLocks.RemoveRange(locks);
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation("Released {Count} locks", locks.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear local tracking
|
||||||
|
_heldLocks.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<int> CleanupExpiredLocksAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var expiredLocks = await context.DistributedLocks
|
||||||
|
.Where(l => l.ExpiresAt < now)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (expiredLocks.Count > 0)
|
||||||
|
{
|
||||||
|
context.DistributedLocks.RemoveRange(expiredLocks);
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogDebug("Cleaned up {Count} expired locks", expiredLocks.Count);
|
||||||
|
|
||||||
|
// Remove from local tracking
|
||||||
|
foreach (var expiredLock in expiredLocks)
|
||||||
|
{
|
||||||
|
_heldLocks.TryRemove(expiredLock.LockName, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expiredLocks.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanupTimer?.Dispose();
|
||||||
|
|
||||||
|
// Release all locks on disposal
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ReleaseAllLocksAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error releasing locks during disposal");
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<DistributedLockHandle?> AcquireLockInternalAsync(
|
||||||
|
string lockName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Use a transaction for atomic lock acquisition
|
||||||
|
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check if lock exists
|
||||||
|
var existingLock = await context.DistributedLocks
|
||||||
|
.FirstOrDefaultAsync(l => l.LockName == lockName, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (existingLock is not null)
|
||||||
|
{
|
||||||
|
// Check if it's expired
|
||||||
|
if (existingLock.IsExpired())
|
||||||
|
{
|
||||||
|
// Remove expired lock
|
||||||
|
context.DistributedLocks.Remove(existingLock);
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Lock is still valid and held by another instance
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Lock '{LockName}' is held by instance {HolderId} until {ExpiresAt}",
|
||||||
|
lockName,
|
||||||
|
existingLock.InstanceId,
|
||||||
|
existingLock.ExpiresAt);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new lock
|
||||||
|
var expiresAt = DateTime.UtcNow.Add(_defaultLockDuration);
|
||||||
|
var newLock = new DistributedLock(lockName, _instanceId, expiresAt);
|
||||||
|
|
||||||
|
context.DistributedLocks.Add(newLock);
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Acquired lock '{LockName}' for instance {InstanceId} until {ExpiresAt}",
|
||||||
|
lockName,
|
||||||
|
_instanceId,
|
||||||
|
expiresAt);
|
||||||
|
|
||||||
|
// Create handle
|
||||||
|
var handle = new DistributedLockHandle(
|
||||||
|
lockName,
|
||||||
|
_instanceId,
|
||||||
|
newLock.AcquiredAt,
|
||||||
|
expiresAt,
|
||||||
|
this);
|
||||||
|
|
||||||
|
_heldLocks.TryAdd(lockName, handle);
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex)
|
||||||
|
{
|
||||||
|
// Likely a race condition - another instance acquired the lock first
|
||||||
|
_logger.LogDebug(ex, "Concurrent lock acquisition detected for '{LockName}'", lockName);
|
||||||
|
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error acquiring lock '{LockName}'", lockName);
|
||||||
|
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReleaseLockInternalAsync(string lockName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var lockEntity = await context.DistributedLocks
|
||||||
|
.FirstOrDefaultAsync(l => l.LockName == lockName && l.InstanceId.Equals(_instanceId), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (lockEntity is not null)
|
||||||
|
{
|
||||||
|
context.DistributedLocks.Remove(lockEntity);
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogDebug("Released lock '{LockName}'", lockName);
|
||||||
|
}
|
||||||
|
|
||||||
|
_heldLocks.TryRemove(lockName, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RenewLockInternalAsync(
|
||||||
|
string lockName,
|
||||||
|
TimeSpan additionalTime,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var lockEntity = await context.DistributedLocks
|
||||||
|
.FirstOrDefaultAsync(l => l.LockName == lockName && l.InstanceId.Equals(_instanceId), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (lockEntity is not null)
|
||||||
|
{
|
||||||
|
lockEntity.Renew(additionalTime);
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogDebug("Renewed lock '{LockName}' until {ExpiresAt}", lockName, lockEntity.ExpiresAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupTimerCallback(object? state)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CleanupExpiredLocksAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during periodic lock cleanup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal lock handle implementation.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class DistributedLockHandle : IDistributedLockHandle
|
||||||
|
{
|
||||||
|
private readonly DistributedLockManager _manager;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DistributedLockHandle"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lockName">The lock name.</param>
|
||||||
|
/// <param name="instanceId">The instance ID.</param>
|
||||||
|
/// <param name="acquiredAt">The acquisition time.</param>
|
||||||
|
/// <param name="expiresAt">The expiration time.</param>
|
||||||
|
/// <param name="manager">The parent manager.</param>
|
||||||
|
public DistributedLockHandle(
|
||||||
|
string lockName,
|
||||||
|
Guid instanceId,
|
||||||
|
DateTime acquiredAt,
|
||||||
|
DateTime expiresAt,
|
||||||
|
DistributedLockManager manager)
|
||||||
|
{
|
||||||
|
LockName = lockName;
|
||||||
|
InstanceId = instanceId;
|
||||||
|
AcquiredAt = acquiredAt;
|
||||||
|
ExpiresAt = expiresAt;
|
||||||
|
_manager = manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string LockName { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid InstanceId { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime AcquiredAt { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime ExpiresAt { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsValid => !_disposed && DateTime.UtcNow < ExpiresAt;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RenewAsync(TimeSpan additionalTime, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(DistributedLockHandle));
|
||||||
|
}
|
||||||
|
|
||||||
|
await _manager.RenewLockInternalAsync(LockName, additionalTime, cancellationToken).ConfigureAwait(false);
|
||||||
|
ExpiresAt = DateTime.UtcNow.Add(additionalTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _manager.ReleaseLockInternalAsync(LockName, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_manager._logger.LogError(ex, "Error releasing lock '{LockName}' during disposal", LockName);
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// <copyright file="DistributedLockNames.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard lock names for distributed locking.
|
||||||
|
/// </summary>
|
||||||
|
public static class DistributedLockNames
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lock for database migrations.
|
||||||
|
/// </summary>
|
||||||
|
public const string DatabaseMigration = "database:migration";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lock for primary instance election.
|
||||||
|
/// </summary>
|
||||||
|
public const string PrimaryElection = "instance:primary:election";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Global lock for all library scans.
|
||||||
|
/// </summary>
|
||||||
|
public const string GlobalLibraryScan = "library:scan:global";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lock for library scanning operations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryId">The library ID.</param>
|
||||||
|
/// <returns>The lock name.</returns>
|
||||||
|
public static string LibraryScan(Guid libraryId)
|
||||||
|
{
|
||||||
|
return $"library:scan:{libraryId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lock for metadata refresh operations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The item ID.</param>
|
||||||
|
/// <returns>The lock name.</returns>
|
||||||
|
public static string MetadataRefresh(Guid itemId)
|
||||||
|
{
|
||||||
|
return $"metadata:refresh:{itemId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lock for configuration updates.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configType">The configuration type.</param>
|
||||||
|
/// <returns>The lock name.</returns>
|
||||||
|
public static string ConfigurationUpdate(string configType)
|
||||||
|
{
|
||||||
|
return $"config:update:{configType}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lock for scheduled task execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="taskName">The task name.</param>
|
||||||
|
/// <returns>The lock name.</returns>
|
||||||
|
public static string ScheduledTask(string taskName)
|
||||||
|
{
|
||||||
|
return $"task:{taskName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lock for image processing operations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The item ID.</param>
|
||||||
|
/// <returns>The lock name.</returns>
|
||||||
|
public static string ImageProcessing(Guid itemId)
|
||||||
|
{
|
||||||
|
return $"image:process:{itemId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lock for cache clearing operations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cacheType">The cache type.</param>
|
||||||
|
/// <returns>The lock name.</returns>
|
||||||
|
public static string CacheClear(string cacheType)
|
||||||
|
{
|
||||||
|
return $"cache:clear:{cacheType}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lock for plugin operations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pluginId">The plugin ID.</param>
|
||||||
|
/// <returns>The lock name.</returns>
|
||||||
|
public static string PluginOperation(Guid pluginId)
|
||||||
|
{
|
||||||
|
return $"plugin:operation:{pluginId}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
// <copyright file="FileSystemChangeProcessor.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes file system changes from the database.
|
||||||
|
/// Only the primary instance processes changes; all instances can record them.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileSystemChangeProcessor : IFileSystemChangeProcessor, IHostedService, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||||
|
private readonly IInstanceRegistry _instanceRegistry;
|
||||||
|
private readonly IPrimaryElectionService _primaryElectionService;
|
||||||
|
private readonly ILibraryManager? _libraryManager;
|
||||||
|
private readonly ILogger<FileSystemChangeProcessor> _logger;
|
||||||
|
private CancellationTokenSource? _processingCts;
|
||||||
|
private Task? _processingTask;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="FileSystemChangeProcessor"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dbContextFactory">The database context factory.</param>
|
||||||
|
/// <param name="instanceRegistry">The instance registry.</param>
|
||||||
|
/// <param name="primaryElectionService">The primary election service.</param>
|
||||||
|
/// <param name="libraryManager">The library manager (optional, may not be available at startup).</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public FileSystemChangeProcessor(
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||||
|
IInstanceRegistry instanceRegistry,
|
||||||
|
IPrimaryElectionService primaryElectionService,
|
||||||
|
ILibraryManager? libraryManager,
|
||||||
|
ILogger<FileSystemChangeProcessor> logger)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_instanceRegistry = instanceRegistry;
|
||||||
|
_primaryElectionService = primaryElectionService;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
Task IHostedService.StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return StartAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
Task IHostedService.StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return StopAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting file system change processor");
|
||||||
|
|
||||||
|
// Subscribe to primary changes
|
||||||
|
_primaryElectionService.PrimaryInstanceChanged += OnPrimaryInstanceChanged;
|
||||||
|
|
||||||
|
// If we're currently primary, start processing
|
||||||
|
if (_primaryElectionService.IsPrimary)
|
||||||
|
{
|
||||||
|
StartProcessingLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("File system change processor started");
|
||||||
|
await Task.CompletedTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Stopping file system change processor");
|
||||||
|
|
||||||
|
_primaryElectionService.PrimaryInstanceChanged -= OnPrimaryInstanceChanged;
|
||||||
|
|
||||||
|
StopProcessingLoop();
|
||||||
|
|
||||||
|
_logger.LogInformation("File system change processor stopped");
|
||||||
|
await Task.CompletedTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task RecordChangeAsync(string path, string changeType, string? oldPath = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var change = new FileSystemChange
|
||||||
|
{
|
||||||
|
Path = path,
|
||||||
|
ChangeType = changeType,
|
||||||
|
OldPath = oldPath,
|
||||||
|
DetectedAt = DateTime.UtcNow,
|
||||||
|
DetectedBy = _instanceRegistry.CurrentInstanceId
|
||||||
|
};
|
||||||
|
|
||||||
|
context.FileSystemChanges.Add(change);
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Recorded file system change: {ChangeType} - {Path}",
|
||||||
|
changeType,
|
||||||
|
path);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to record file system change for {Path}", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
await StopAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_processingCts?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPrimaryInstanceChanged(object? sender, PrimaryInstanceChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.IsCurrentInstance)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Became primary instance, starting file system change processing");
|
||||||
|
StartProcessingLoop();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No longer primary instance, stopping file system change processing");
|
||||||
|
StopProcessingLoop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartProcessingLoop()
|
||||||
|
{
|
||||||
|
if (_processingTask is not null)
|
||||||
|
{
|
||||||
|
return; // Already running
|
||||||
|
}
|
||||||
|
|
||||||
|
_processingCts = new CancellationTokenSource();
|
||||||
|
_processingTask = ProcessChangesLoopAsync(_processingCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopProcessingLoop()
|
||||||
|
{
|
||||||
|
if (_processingTask is null)
|
||||||
|
{
|
||||||
|
return; // Not running
|
||||||
|
}
|
||||||
|
|
||||||
|
_processingCts?.Cancel();
|
||||||
|
_processingTask = null;
|
||||||
|
_processingCts?.Dispose();
|
||||||
|
_processingCts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessChangesLoopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("File system change processing loop started");
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Process a batch of changes
|
||||||
|
await ProcessPendingChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Wait before next batch (5 seconds)
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error in file system change processing loop");
|
||||||
|
|
||||||
|
// Wait before retrying on error
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("File system change processing loop stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessPendingChangesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Get unprocessed changes (oldest first, limit 100 per batch)
|
||||||
|
var changes = await context.FileSystemChanges
|
||||||
|
.Where(c => c.ProcessedAt == null)
|
||||||
|
.OrderBy(c => c.DetectedAt)
|
||||||
|
.Take(100)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (changes.Count == 0)
|
||||||
|
{
|
||||||
|
return; // Nothing to process
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Processing {Count} file system changes", changes.Count);
|
||||||
|
|
||||||
|
foreach (var change in changes)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ProcessSingleChangeAsync(change, context, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save all processed changes
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save processed file system changes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessSingleChangeAsync(FileSystemChange change, JellyfinDbContext context, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_libraryManager is null)
|
||||||
|
{
|
||||||
|
// LibraryManager not available yet (still starting up)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the change based on type
|
||||||
|
switch (change.ChangeType)
|
||||||
|
{
|
||||||
|
case "Created":
|
||||||
|
case "Modified":
|
||||||
|
// LibraryManager will handle these through its file watcher
|
||||||
|
// We just mark as processed since detection happened
|
||||||
|
_logger.LogDebug("File change detected: {Type} - {Path}", change.ChangeType, change.Path);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Deleted":
|
||||||
|
_logger.LogDebug("File deleted: {Path}", change.Path);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Renamed":
|
||||||
|
_logger.LogDebug("File renamed from {OldPath} to {NewPath}", change.OldPath, change.Path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as processed
|
||||||
|
change.ProcessedAt = DateTime.UtcNow;
|
||||||
|
change.ProcessedBy = _instanceRegistry.CurrentInstanceId;
|
||||||
|
|
||||||
|
// Note: Actual file refresh is handled by LibraryManager's existing file watcher
|
||||||
|
// This processor just coordinates change detection across instances
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing file system change for {Path}", change.Path);
|
||||||
|
change.Error = ex.Message;
|
||||||
|
change.ProcessedAt = DateTime.UtcNow;
|
||||||
|
change.ProcessedBy = _instanceRegistry.CurrentInstanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// <copyright file="ICacheCoordinator.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for coordinating cache invalidation across multiple instances.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICacheCoordinator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the cache coordinator.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task StartAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the cache coordinator.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task StopAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates an item cache across all instances.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The item ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task InvalidateItemAsync(Guid itemId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates user data cache across all instances.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The user ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task InvalidateUserDataAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates an image cache across all instances.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The item ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task InvalidateImageAsync(Guid itemId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates all caches of a specific type across all instances.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cacheType">The cache type.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task InvalidateAllAsync(CacheInvalidationType cacheType, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates a library cache across all instances.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryId">The library ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task InvalidateLibraryAsync(Guid libraryId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates a person cache across all instances.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="personId">The person ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task InvalidatePersonAsync(Guid personId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates metadata for an item across all instances.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The item ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task InvalidateMetadataAsync(Guid itemId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// <copyright file="IDistributedLockManager.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for managing distributed locks across multiple instances.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDistributedLockManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to acquire a distributed lock.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lockName">The unique name of the lock.</param>
|
||||||
|
/// <param name="timeout">The maximum time to wait for the lock.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A disposable lock handle, or null if lock could not be acquired.</returns>
|
||||||
|
Task<IDistributedLockHandle?> TryAcquireLockAsync(
|
||||||
|
string lockName,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Acquires a distributed lock, waiting indefinitely if necessary.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lockName">The unique name of the lock.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A disposable lock handle.</returns>
|
||||||
|
Task<IDistributedLockHandle> AcquireLockAsync(
|
||||||
|
string lockName,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a lock is currently held.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lockName">The name of the lock to check.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>True if the lock is held, false otherwise.</returns>
|
||||||
|
Task<bool> IsLockHeldAsync(
|
||||||
|
string lockName,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases all locks held by the current instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task ReleaseAllLocksAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cleans up expired locks from the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The number of locks cleaned up.</returns>
|
||||||
|
Task<int> CleanupExpiredLocksAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a handle to a distributed lock that can be disposed to release the lock.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDistributedLockHandle : IAsyncDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the lock.
|
||||||
|
/// </summary>
|
||||||
|
string LockName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the instance ID holding the lock.
|
||||||
|
/// </summary>
|
||||||
|
Guid InstanceId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the time when the lock was acquired.
|
||||||
|
/// </summary>
|
||||||
|
DateTime AcquiredAt { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the time when the lock will expire.
|
||||||
|
/// </summary>
|
||||||
|
DateTime ExpiresAt { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the lock is still valid.
|
||||||
|
/// </summary>
|
||||||
|
bool IsValid { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renews the lock by extending its expiration time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="additionalTime">The additional time to extend the lock.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task RenewAsync(TimeSpan additionalTime, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// <copyright file="IFileSystemChangeProcessor.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service that processes file system changes from the database (primary instance only).
|
||||||
|
/// </summary>
|
||||||
|
public interface IFileSystemChangeProcessor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the file system change processor.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
Task StartAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the file system change processor.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
Task StopAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a file system change to the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path that changed.</param>
|
||||||
|
/// <param name="changeType">The type of change (Created, Modified, Deleted, Renamed).</param>
|
||||||
|
/// <param name="oldPath">The old path for rename operations.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
Task RecordChangeAsync(string path, string changeType, string? oldPath = null, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// <copyright file="IInstanceRegistry.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for managing Jellyfin instance registration and lifecycle.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInstanceRegistry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique identifier for the current instance.
|
||||||
|
/// </summary>
|
||||||
|
Guid CurrentInstanceId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the current instance in the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hostname">The hostname of the instance.</param>
|
||||||
|
/// <param name="processId">The process ID of the instance.</param>
|
||||||
|
/// <param name="httpPort">The HTTP port.</param>
|
||||||
|
/// <param name="httpsPort">The HTTPS port (optional).</param>
|
||||||
|
/// <param name="version">The Jellyfin version.</param>
|
||||||
|
/// <param name="capabilities">Instance capabilities (JSON).</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task RegisterInstanceAsync(
|
||||||
|
string hostname,
|
||||||
|
int processId,
|
||||||
|
int httpPort,
|
||||||
|
int? httpsPort,
|
||||||
|
string version,
|
||||||
|
string? capabilities,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the heartbeat timestamp for the current instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task UpdateHeartbeatAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks the current instance as shutdown in the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task ShutdownInstanceAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all active instances from the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>List of active instances.</returns>
|
||||||
|
Task<IReadOnlyList<Instance>> GetActiveInstancesAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a specific instance by ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="instanceId">The instance ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The instance, or null if not found.</returns>
|
||||||
|
Task<Instance?> GetInstanceAsync(Guid instanceId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes stale instances from the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="staleThreshold">Time after which an instance is considered stale.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Number of instances removed.</returns>
|
||||||
|
Task<int> CleanupStaleInstancesAsync(TimeSpan staleThreshold, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the current instance is the primary instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>True if this instance is primary.</returns>
|
||||||
|
Task<bool> IsPrimaryInstanceAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// <copyright file="IPostgresNotificationListener.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for listening to PostgreSQL NOTIFY messages.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPostgresNotificationListener
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Event fired when a notification is received.
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler<PostgresNotificationEventArgs>? NotificationReceived;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts listening for notifications on the specified channel.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channel">The channel name to listen on.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task StartListeningAsync(string channel, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops listening for notifications.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task StopListeningAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a notification to a channel.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channel">The channel name.</param>
|
||||||
|
/// <param name="payload">The notification payload.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task NotifyAsync(string channel, string payload, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event arguments for PostgreSQL notifications.
|
||||||
|
/// </summary>
|
||||||
|
public class PostgresNotificationEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PostgresNotificationEventArgs"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channel">The channel name.</param>
|
||||||
|
/// <param name="payload">The notification payload.</param>
|
||||||
|
public PostgresNotificationEventArgs(string channel, string payload)
|
||||||
|
{
|
||||||
|
Channel = channel;
|
||||||
|
Payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the channel name.
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the notification payload.
|
||||||
|
/// </summary>
|
||||||
|
public string Payload { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// <copyright file="IPrimaryElectionService.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing primary instance election and coordination.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPrimaryElectionService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Event raised when the primary instance changes.
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler<PrimaryInstanceChangedEventArgs>? PrimaryInstanceChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the current instance is the primary instance.
|
||||||
|
/// </summary>
|
||||||
|
bool IsPrimary { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the ID of the current primary instance, if any.
|
||||||
|
/// </summary>
|
||||||
|
Guid? PrimaryInstanceId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the primary election service and begins monitoring.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
Task StartAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the primary election service.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
Task StopAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the primary election algorithm.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The ID of the elected primary instance.</returns>
|
||||||
|
Task<Guid?> ElectPrimaryAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if this instance should execute scheduled tasks.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>True if this instance should execute scheduled tasks, false otherwise.</returns>
|
||||||
|
bool ShouldExecuteScheduledTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// <copyright file="InstanceRegistry.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages Jellyfin instance registration and lifecycle.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InstanceRegistry : IInstanceRegistry, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||||
|
private readonly ILogger<InstanceRegistry> _logger;
|
||||||
|
private readonly Timer _heartbeatTimer;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="InstanceRegistry"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dbContextFactory">The database context factory.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public InstanceRegistry(
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||||
|
ILogger<InstanceRegistry> logger)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_logger = logger;
|
||||||
|
CurrentInstanceId = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Send heartbeat every 30 seconds
|
||||||
|
_heartbeatTimer = new Timer(
|
||||||
|
HeartbeatCallback,
|
||||||
|
null,
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique identifier for the current instance.
|
||||||
|
/// </summary>
|
||||||
|
public Guid CurrentInstanceId { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task RegisterInstanceAsync(
|
||||||
|
string hostname,
|
||||||
|
int processId,
|
||||||
|
int httpPort,
|
||||||
|
int? httpsPort,
|
||||||
|
string version,
|
||||||
|
string? capabilities,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var instance = new Instance(
|
||||||
|
CurrentInstanceId,
|
||||||
|
hostname,
|
||||||
|
processId,
|
||||||
|
httpPort,
|
||||||
|
version)
|
||||||
|
{
|
||||||
|
HttpsPort = httpsPort
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(capabilities))
|
||||||
|
{
|
||||||
|
instance.SetCapabilities(capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.Instances.Add(instance);
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Registered instance {InstanceId} ({Hostname}:{Port})",
|
||||||
|
CurrentInstanceId,
|
||||||
|
hostname,
|
||||||
|
httpPort);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to register instance {InstanceId}", CurrentInstanceId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task UpdateHeartbeatAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var instance = await dbContext.Instances
|
||||||
|
.FirstOrDefaultAsync(i => i.InstanceId.Equals(CurrentInstanceId), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (instance is not null)
|
||||||
|
{
|
||||||
|
instance.UpdateHeartbeat();
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to update heartbeat for instance {InstanceId}", CurrentInstanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task ShutdownInstanceAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var instance = await dbContext.Instances
|
||||||
|
.FirstOrDefaultAsync(i => i.InstanceId.Equals(CurrentInstanceId), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (instance is not null)
|
||||||
|
{
|
||||||
|
instance.UpdateStatus(InstanceStatus.Shutdown);
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation("Instance {InstanceId} marked as shutdown", CurrentInstanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to shutdown instance {InstanceId}", CurrentInstanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<Instance>> GetActiveInstancesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return await dbContext.Instances
|
||||||
|
.Where(i => i.Status == InstanceStatus.Active)
|
||||||
|
.Where(i => !i.IsStale(TimeSpan.FromMinutes(2)))
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<Instance?> GetInstanceAsync(Guid instanceId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return await dbContext.Instances
|
||||||
|
.FirstOrDefaultAsync(i => i.InstanceId.Equals(instanceId), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<int> CleanupStaleInstancesAsync(TimeSpan staleThreshold, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var cutoffTime = DateTime.UtcNow - staleThreshold;
|
||||||
|
|
||||||
|
var staleInstances = await dbContext.Instances
|
||||||
|
.Where(i => i.LastHeartbeat < cutoffTime)
|
||||||
|
.Where(i => i.Status != InstanceStatus.Shutdown)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (staleInstances.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var instance in staleInstances)
|
||||||
|
{
|
||||||
|
instance.UpdateStatus(InstanceStatus.Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Marked {Count} stale instances as failed",
|
||||||
|
staleInstances.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return staleInstances.Count;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to cleanup stale instances");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<bool> IsPrimaryInstanceAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var instance = await dbContext.Instances
|
||||||
|
.FirstOrDefaultAsync(i => i.InstanceId.Equals(CurrentInstanceId), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return instance?.IsPrimary ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
await _heartbeatTimer.DisposeAsync().ConfigureAwait(false);
|
||||||
|
await ShutdownInstanceAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HeartbeatCallback(object? state)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await UpdateHeartbeatAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Heartbeat timer error for instance {InstanceId}", CurrentInstanceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
// <copyright file="PostgresNotificationListener.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Listens for PostgreSQL NOTIFY messages for cross-instance communication.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PostgresNotificationListener : IPostgresNotificationListener, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||||
|
private readonly ILogger<PostgresNotificationListener> _logger;
|
||||||
|
private NpgsqlConnection? _connection;
|
||||||
|
private Task? _listenerTask;
|
||||||
|
private CancellationTokenSource? _listenerCts;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PostgresNotificationListener"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dbContextFactory">The database context factory.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public PostgresNotificationListener(
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||||
|
ILogger<PostgresNotificationListener> logger)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event EventHandler<PostgresNotificationEventArgs>? NotificationReceived;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task StartListeningAsync(string channel, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_connection is not null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Already listening on channel {Channel}", channel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create a dedicated connection for listening
|
||||||
|
await using var tempContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var connectionString = tempContext.Database.GetConnectionString();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(connectionString))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Connection string not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
_connection = new NpgsqlConnection(connectionString);
|
||||||
|
await _connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_connection.Notification += OnNotification;
|
||||||
|
|
||||||
|
// Subscribe to the channel
|
||||||
|
await using (var cmd = new NpgsqlCommand($"LISTEN {channel}", _connection))
|
||||||
|
{
|
||||||
|
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Started listening on PostgreSQL channel: {Channel}", channel);
|
||||||
|
|
||||||
|
// Start the listener loop
|
||||||
|
_listenerCts = new CancellationTokenSource();
|
||||||
|
_listenerTask = ListenLoopAsync(_listenerCts.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to start listening on channel {Channel}", channel);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task StopListeningAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_connection is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listenerCts?.Cancel();
|
||||||
|
|
||||||
|
if (_listenerTask is not null)
|
||||||
|
{
|
||||||
|
await _listenerTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_connection is not null)
|
||||||
|
{
|
||||||
|
_connection.Notification -= OnNotification;
|
||||||
|
await _connection.CloseAsync().ConfigureAwait(false);
|
||||||
|
await _connection.DisposeAsync().ConfigureAwait(false);
|
||||||
|
_connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Stopped listening for PostgreSQL notifications");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error stopping notification listener");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task NotifyAsync(string channel, string payload, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Escape the payload for PostgreSQL
|
||||||
|
var escapedPayload = payload.Replace("'", "''", StringComparison.Ordinal);
|
||||||
|
var sql = $"NOTIFY {channel}, '{escapedPayload}'";
|
||||||
|
|
||||||
|
await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogDebug("Sent notification on channel {Channel}", channel);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to send notification on channel {Channel}", channel);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
await StopListeningAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_listenerCts?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ListenLoopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested && _connection is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Wait for notifications
|
||||||
|
await _connection.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error in notification listener loop");
|
||||||
|
|
||||||
|
// Wait a bit before retrying
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(5000, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNotification(object? sender, NpgsqlNotificationEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Received notification on channel {Channel}: {Payload}", e.Channel, e.Payload);
|
||||||
|
NotificationReceived?.Invoke(this, new PostgresNotificationEventArgs(e.Channel, e.Payload));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling notification");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// <copyright file="PrimaryElectionService.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service that manages primary instance election and coordination.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PrimaryElectionService : IPrimaryElectionService, IHostedService, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||||
|
private readonly IInstanceRegistry _instanceRegistry;
|
||||||
|
private readonly ILogger<PrimaryElectionService> _logger;
|
||||||
|
private CancellationTokenSource? _monitoringCts;
|
||||||
|
private Task? _monitoringTask;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PrimaryElectionService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dbContextFactory">The database context factory.</param>
|
||||||
|
/// <param name="instanceRegistry">The instance registry.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public PrimaryElectionService(
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||||
|
IInstanceRegistry instanceRegistry,
|
||||||
|
ILogger<PrimaryElectionService> logger)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_instanceRegistry = instanceRegistry;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event EventHandler<PrimaryInstanceChangedEventArgs>? PrimaryInstanceChanged;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsPrimary { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Guid? PrimaryInstanceId { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
Task IHostedService.StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return StartAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
Task IHostedService.StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return StopAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting primary election service");
|
||||||
|
|
||||||
|
// Run initial election
|
||||||
|
await ElectPrimaryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Start background monitoring
|
||||||
|
_monitoringCts = new CancellationTokenSource();
|
||||||
|
_monitoringTask = MonitorPrimaryStatusAsync(_monitoringCts.Token);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Primary election service started. Current instance is primary: {IsPrimary}",
|
||||||
|
IsPrimary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Stopping primary election service");
|
||||||
|
|
||||||
|
if (_monitoringCts is not null)
|
||||||
|
{
|
||||||
|
_monitoringCts.Cancel();
|
||||||
|
|
||||||
|
if (_monitoringTask is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _monitoringTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we were primary, relinquish it
|
||||||
|
if (IsPrimary)
|
||||||
|
{
|
||||||
|
await RelinquishPrimaryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Primary election service stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<Guid?> ElectPrimaryAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Call the database function to elect primary
|
||||||
|
var result = await context.Database
|
||||||
|
.SqlQuery<Guid?>($"SELECT library.elect_primary_instance()")
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var previousPrimaryId = PrimaryInstanceId;
|
||||||
|
PrimaryInstanceId = result;
|
||||||
|
IsPrimary = result.HasValue && result.Value.Equals(_instanceRegistry.CurrentInstanceId);
|
||||||
|
|
||||||
|
// Raise event if primary changed
|
||||||
|
if (!previousPrimaryId.Equals(PrimaryInstanceId))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Primary instance changed from {PreviousPrimaryId} to {NewPrimaryId}. Current instance is primary: {IsPrimary}",
|
||||||
|
previousPrimaryId,
|
||||||
|
PrimaryInstanceId,
|
||||||
|
IsPrimary);
|
||||||
|
|
||||||
|
PrimaryInstanceChanged?.Invoke(
|
||||||
|
this,
|
||||||
|
new PrimaryInstanceChangedEventArgs(previousPrimaryId, PrimaryInstanceId, IsPrimary));
|
||||||
|
}
|
||||||
|
|
||||||
|
return PrimaryInstanceId;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to elect primary instance");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool ShouldExecuteScheduledTasks()
|
||||||
|
{
|
||||||
|
// Only the primary instance should execute scheduled tasks
|
||||||
|
return IsPrimary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
await StopAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_monitoringCts?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MonitorPrimaryStatusAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Primary status monitoring started");
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check primary status every 30 seconds
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await CheckAndUpdatePrimaryStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error in primary status monitoring loop");
|
||||||
|
|
||||||
|
// Wait before retrying
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Primary status monitoring stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CheckAndUpdatePrimaryStatusAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Check if current primary is still active
|
||||||
|
var currentPrimary = await context.Database
|
||||||
|
.SqlQuery<Guid?>($"SELECT library.get_primary_instance()")
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// If no primary exists or primary changed, run election
|
||||||
|
if (!currentPrimary.Equals(PrimaryInstanceId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Primary instance status changed. Current: {CurrentPrimary}, Expected: {ExpectedPrimary}. Running election.",
|
||||||
|
currentPrimary,
|
||||||
|
PrimaryInstanceId);
|
||||||
|
|
||||||
|
await ElectPrimaryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to check primary status");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RelinquishPrimaryAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Clear IsPrimary flag for this instance
|
||||||
|
await context.Database
|
||||||
|
.ExecuteSqlAsync(
|
||||||
|
$@"UPDATE library.""Instances""
|
||||||
|
SET ""IsPrimary"" = FALSE
|
||||||
|
WHERE ""InstanceId"" = {_instanceRegistry.CurrentInstanceId}",
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
IsPrimary = false;
|
||||||
|
_logger.LogInformation("Relinquished primary status");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to relinquish primary status");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// <copyright file="PrimaryInstanceChangedEventArgs.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event arguments for when the primary instance changes.
|
||||||
|
/// </summary>
|
||||||
|
public class PrimaryInstanceChangedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PrimaryInstanceChangedEventArgs"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="previousPrimaryId">The previous primary instance ID.</param>
|
||||||
|
/// <param name="newPrimaryId">The new primary instance ID.</param>
|
||||||
|
/// <param name="isCurrentInstance">Whether the current instance is now primary.</param>
|
||||||
|
public PrimaryInstanceChangedEventArgs(Guid? previousPrimaryId, Guid? newPrimaryId, bool isCurrentInstance)
|
||||||
|
{
|
||||||
|
PreviousPrimaryId = previousPrimaryId;
|
||||||
|
NewPrimaryId = newPrimaryId;
|
||||||
|
IsCurrentInstance = isCurrentInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the previous primary instance ID.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? PreviousPrimaryId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the new primary instance ID.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? NewPrimaryId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the current instance is now the primary.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsCurrentInstance { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// <copyright file="PrimaryInstanceTaskManager.cs" company="PlaceholderCompany">
|
||||||
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Clustering
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Events;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task manager decorator that ensures scheduled tasks only run on the primary instance.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PrimaryInstanceTaskManager : ITaskManager
|
||||||
|
{
|
||||||
|
private readonly ITaskManager _innerTaskManager;
|
||||||
|
private readonly IPrimaryElectionService _primaryElectionService;
|
||||||
|
private readonly ILogger<PrimaryInstanceTaskManager> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PrimaryInstanceTaskManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="innerTaskManager">The inner task manager.</param>
|
||||||
|
/// <param name="primaryElectionService">The primary election service.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public PrimaryInstanceTaskManager(
|
||||||
|
ITaskManager innerTaskManager,
|
||||||
|
IPrimaryElectionService primaryElectionService,
|
||||||
|
ILogger<PrimaryInstanceTaskManager> logger)
|
||||||
|
{
|
||||||
|
_innerTaskManager = innerTaskManager;
|
||||||
|
_primaryElectionService = primaryElectionService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event EventHandler<GenericEventArgs<IScheduledTaskWorker>>? TaskExecuting
|
||||||
|
{
|
||||||
|
add => _innerTaskManager.TaskExecuting += value;
|
||||||
|
remove => _innerTaskManager.TaskExecuting -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event EventHandler<TaskCompletionEventArgs>? TaskCompleted
|
||||||
|
{
|
||||||
|
add => _innerTaskManager.TaskCompleted += value;
|
||||||
|
remove => _innerTaskManager.TaskCompleted -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public IReadOnlyList<IScheduledTaskWorker> ScheduledTasks => _innerTaskManager.ScheduledTasks;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void CancelIfRunningAndQueue<T>(TaskOptions options)
|
||||||
|
where T : IScheduledTask
|
||||||
|
{
|
||||||
|
if (ShouldExecuteTask<T>())
|
||||||
|
{
|
||||||
|
_innerTaskManager.CancelIfRunningAndQueue<T>(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void CancelIfRunningAndQueue<T>()
|
||||||
|
where T : IScheduledTask
|
||||||
|
{
|
||||||
|
if (ShouldExecuteTask<T>())
|
||||||
|
{
|
||||||
|
_innerTaskManager.CancelIfRunningAndQueue<T>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void CancelIfRunning<T>()
|
||||||
|
where T : IScheduledTask
|
||||||
|
{
|
||||||
|
_innerTaskManager.CancelIfRunning<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void QueueScheduledTask<T>(TaskOptions options)
|
||||||
|
where T : IScheduledTask
|
||||||
|
{
|
||||||
|
if (ShouldExecuteTask<T>())
|
||||||
|
{
|
||||||
|
_innerTaskManager.QueueScheduledTask<T>(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void QueueScheduledTask<T>()
|
||||||
|
where T : IScheduledTask
|
||||||
|
{
|
||||||
|
if (ShouldExecuteTask<T>())
|
||||||
|
{
|
||||||
|
_innerTaskManager.QueueScheduledTask<T>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void QueueIfNotRunning<T>()
|
||||||
|
where T : IScheduledTask
|
||||||
|
{
|
||||||
|
if (ShouldExecuteTask<T>())
|
||||||
|
{
|
||||||
|
_innerTaskManager.QueueIfNotRunning<T>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Execute<T>()
|
||||||
|
where T : IScheduledTask
|
||||||
|
{
|
||||||
|
if (ShouldExecuteTask<T>())
|
||||||
|
{
|
||||||
|
_innerTaskManager.Execute<T>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void QueueScheduledTask(IScheduledTask task, TaskOptions options)
|
||||||
|
{
|
||||||
|
if (ShouldExecuteTask(task.GetType()))
|
||||||
|
{
|
||||||
|
_innerTaskManager.QueueScheduledTask(task, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void AddTasks(IEnumerable<IScheduledTask> tasks)
|
||||||
|
{
|
||||||
|
_innerTaskManager.AddTasks(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Cancel(IScheduledTaskWorker task)
|
||||||
|
{
|
||||||
|
_innerTaskManager.Cancel(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task Execute(IScheduledTaskWorker task, TaskOptions options)
|
||||||
|
{
|
||||||
|
if (ShouldExecuteTask(task.ScheduledTask.GetType()))
|
||||||
|
{
|
||||||
|
return _innerTaskManager.Execute(task, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_innerTaskManager.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ShouldExecuteTask<T>()
|
||||||
|
where T : IScheduledTask
|
||||||
|
{
|
||||||
|
return ShouldExecuteTask(typeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ShouldExecuteTask(Type taskType)
|
||||||
|
{
|
||||||
|
if (!_primaryElectionService.ShouldExecuteScheduledTasks())
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping task {TaskName} - not primary instance. Primary: {PrimaryId}",
|
||||||
|
taskType.Name,
|
||||||
|
_primaryElectionService.PrimaryInstanceId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,13 +99,23 @@ public static class ServiceCollectionExtensions
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// PostgreSQL-only build: default to PostgreSQL when no database.xml exists.
|
// when nothing is setup via new Database configuration, fallback to SQLite with default settings.
|
||||||
efCoreConfiguration = new DatabaseConfigurationOptions()
|
efCoreConfiguration = new DatabaseConfigurationOptions()
|
||||||
{
|
{
|
||||||
DatabaseType = "Jellyfin-PostgreSQL",
|
DatabaseType = "Jellyfin-SQLite",
|
||||||
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",
|
||||||
@@ -125,7 +135,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-PostgreSQL";
|
efCoreConfiguration.DatabaseType = "Jellyfin-SQLite";
|
||||||
configurationManager.SaveConfiguration("database", efCoreConfiguration);
|
configurationManager.SaveConfiguration("database", efCoreConfiguration);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,10 +179,14 @@ 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>();
|
||||||
opt.UseLoggerFactory(loggerFactory)
|
if (loggerFactory != null)
|
||||||
.EnableSensitiveDataLogging()
|
{
|
||||||
.EnableDetailedErrors();
|
opt.UseLoggerFactory(loggerFactory)
|
||||||
|
.EnableSensitiveDataLogging()
|
||||||
|
.EnableDetailedErrors();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return serviceCollection;
|
return serviceCollection;
|
||||||
|
|||||||
@@ -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,31 +156,34 @@ 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", $"{MigrationTracker.BackupEntryName}.json")));
|
var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(HistoryRow)}.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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
MigrationRecord[] historyEntries;
|
HistoryRow[] 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<MigrationRecord[]>(historyArchive).ConfigureAwait(false) ??
|
historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
|
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||||
|
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
foreach (var item in await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext, CancellationToken.None).ConfigureAwait(false))
|
foreach (var item in await historyRepository.GetAppliedMigrationsAsync(CancellationToken.None).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await MigrationTracker.DeleteAsync(dbContext, item).ConfigureAwait(false);
|
var insertScript = historyRepository.GetDeleteScript(item.MigrationId);
|
||||||
|
await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var item in historyEntries)
|
foreach (var item in historyEntries)
|
||||||
{
|
{
|
||||||
await MigrationTracker.InsertAsync(dbContext, item.MigrationId, item.ProductVersion).ConfigureAwait(false);
|
var insertScript = historyRepository.GetInsertScript(item);
|
||||||
|
await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
||||||
@@ -307,7 +310,8 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// include the migration history as well
|
// include the migration history as well
|
||||||
var migrations = await MigrationTracker.GetAppliedMigrationsAsync(dbContext).ConfigureAwait(false);
|
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||||
|
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 =
|
||||||
[
|
[
|
||||||
@@ -315,12 +319,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(MigrationRecord), SourceName: MigrationTracker.BackupEntryName, ValueFactory: () => migrations.ToAsyncEnumerable())
|
(Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), 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);
|
||||||
|
|
||||||
var executionStrategy = dbContext.Database.CreateExecutionStrategy();
|
await using (transaction.ConfigureAwait(false))
|
||||||
await executionStrategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Begin Database backup");
|
_logger.LogInformation("Begin Database backup");
|
||||||
|
|
||||||
@@ -359,7 +363,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,25 +77,21 @@ 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);
|
||||||
|
|
||||||
// Use the execution strategy to handle transactional consistency and retries
|
await context.Chapters
|
||||||
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
.Where(e => e.ItemId.Equals(itemId))
|
||||||
var executionStrategy = context.Database.CreateExecutionStrategy();
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
await executionStrategy.ExecuteAsync(async () =>
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
for (var i = 0; i < chapters.Count; i++)
|
||||||
{
|
{
|
||||||
await context.Chapters
|
var chapter = chapters[i];
|
||||||
.Where(e => e.ItemId.Equals(itemId))
|
await context.Chapters.AddAsync(Map(chapter, i, itemId), cancellationToken).ConfigureAwait(false);
|
||||||
.ExecuteDeleteAsync(cancellationToken)
|
}
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
for (var i = 0; i < chapters.Count; i++)
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
{
|
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,16 +64,12 @@ 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);
|
||||||
|
|
||||||
// Use the execution strategy to handle transactional consistency and retries
|
await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||||
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false);
|
||||||
var executionStrategy = context.Database.CreateExecutionStrategy();
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await executionStrategy.ExecuteAsync(async () =>
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
{
|
|
||||||
await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false);
|
|
||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -29,29 +29,25 @@ 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);
|
||||||
|
|
||||||
// Use the execution strategy to handle transactional consistency and retries
|
// Users may replace a media with a version that includes attachments to one without them.
|
||||||
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
// So when saving attachments is triggered by a library scan, we always unconditionally
|
||||||
var executionStrategy = context.Database.CreateExecutionStrategy();
|
// clear the old ones, and then add the new ones if given.
|
||||||
await executionStrategy.ExecuteAsync(async () =>
|
await context.AttachmentStreamInfos
|
||||||
|
.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
|
||||||
.Where(e => e.ItemId.Equals(id))
|
.AddRangeAsync(attachments.Select(e => Map(e, id)), cancellationToken)
|
||||||
.ExecuteDeleteAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (attachments.Any())
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
{
|
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,23 +44,19 @@ 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);
|
||||||
|
|
||||||
// Use the execution strategy to handle transactional consistency and retries
|
await context.MediaStreamInfos
|
||||||
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
.Where(e => e.ItemId.Equals(id))
|
||||||
var executionStrategy = context.Database.CreateExecutionStrategy();
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
await executionStrategy.ExecuteAsync(async () =>
|
.ConfigureAwait(false);
|
||||||
{
|
|
||||||
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);
|
||||||
}).ConfigureAwait(false);
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -68,8 +64,6 @@ 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);
|
||||||
|
|
||||||
@@ -133,54 +127,46 @@ 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;
|
||||||
if (entity.AudioDetails is { } audio)
|
dto.ColorSpace = entity.ColorSpace;
|
||||||
{
|
dto.ColorTransfer = entity.ColorTransfer;
|
||||||
dto.ChannelLayout = audio.ChannelLayout;
|
dto.DvVersionMajor = entity.DvVersionMajor;
|
||||||
dto.Channels = audio.Channels;
|
dto.DvVersionMinor = entity.DvVersionMinor;
|
||||||
dto.SampleRate = audio.SampleRate;
|
dto.DvProfile = entity.DvProfile;
|
||||||
dto.IsHearingImpaired = audio.IsHearingImpaired.GetValueOrDefault();
|
dto.DvLevel = entity.DvLevel;
|
||||||
}
|
dto.RpuPresentFlag = entity.RpuPresentFlag;
|
||||||
|
dto.ElPresentFlag = entity.ElPresentFlag;
|
||||||
if (entity.VideoDetails is { } video)
|
dto.BlPresentFlag = entity.BlPresentFlag;
|
||||||
{
|
dto.DvBlSignalCompatibilityId = entity.DvBlSignalCompatibilityId;
|
||||||
dto.AspectRatio = video.AspectRatio;
|
dto.IsHearingImpaired = entity.IsHearingImpaired.GetValueOrDefault();
|
||||||
dto.IsInterlaced = video.IsInterlaced.GetValueOrDefault();
|
dto.Rotation = entity.Rotation;
|
||||||
dto.Height = video.Height;
|
dto.Hdr10PlusPresentFlag = entity.Hdr10PlusPresentFlag;
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -216,65 +202,47 @@ 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.TvExtras!.SeriesName,
|
(ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
|
||||||
(ItemSortBy.Album, _) => e => e.AudioExtras!.Album,
|
(ItemSortBy.Album, _) => e => e.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.LiveTvExtras!.StartDate,
|
(ItemSortBy.StartDate, _) => e => e.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.TvExtras!.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
|
.Where(w => w.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.TvExtras!.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
|
(ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.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,81 +123,77 @@ 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);
|
||||||
|
|
||||||
// Use the execution strategy to handle transactional consistency and retries
|
var existingPersons = await context.Peoples
|
||||||
// This is required for PostgreSQL NpgsqlRetryingExecutionStrategy
|
.Select(e => new
|
||||||
var executionStrategy = context.Database.CreateExecutionStrategy();
|
|
||||||
await executionStrategy.ExecuteAsync(async () =>
|
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist)
|
item = e,
|
||||||
{
|
SelectionKey = e.Name + "-" + e.PersonType
|
||||||
continue;
|
})
|
||||||
}
|
.Where(p => personKeys.Contains(p.SelectionKey))
|
||||||
|
.Select(f => f.item)
|
||||||
|
.ToArrayAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString());
|
var toAdd = people
|
||||||
var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
|
.Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist)
|
||||||
if (existingMap is null)
|
.Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString()))
|
||||||
{
|
.Select(Map);
|
||||||
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.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)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
context.PeopleBaseItemMap.RemoveRange(existingMaps);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
listOrder++;
|
||||||
}).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,7 +1,6 @@
|
|||||||
<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>
|
||||||
@@ -30,7 +29,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
// <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;
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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,15 +83,6 @@ 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;
|
||||||
@@ -149,7 +140,7 @@ namespace Jellyfin.Server.Implementations.Security
|
|||||||
return authInfo;
|
return authInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenPreview = token?.Length > 8 ? string.Concat(token.AsSpan(0, 8), "...") : token ?? "null";
|
var tokenPreview = token?.Length > 8 ? token.Substring(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",
|
||||||
@@ -255,7 +246,7 @@ namespace Jellyfin.Server.Implementations.Security
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var tokenPreview2 = token?.Length > 8 ? string.Concat(token.AsSpan(0, 8), "...") : token ?? "null";
|
var tokenPreview2 = token?.Length > 8 ? token.Substring(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,6 +28,7 @@ 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;
|
||||||
@@ -42,6 +43,7 @@ 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;
|
||||||
|
|
||||||
@@ -252,7 +254,20 @@ 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)
|
||||||
@@ -312,5 +327,40 @@ 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,11 +31,6 @@ 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>
|
||||||
@@ -240,7 +235,10 @@ public static class StartupHelpers
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(defaultConfig, _jsonSerializerOptions);
|
var json = System.Text.Json.JsonSerializer.Serialize(defaultConfig, new System.Text.Json.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}");
|
||||||
@@ -370,7 +368,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,12 +2,10 @@
|
|||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -16,12 +14,10 @@
|
|||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<ApplicationIcon>Jellyfin.Server.ico</ApplicationIcon>
|
<ApplicationIcon>Jellyfin.Server.ico</ApplicationIcon>
|
||||||
<IsPackable>true</IsPackable>
|
<IsPackable>true</IsPackable>
|
||||||
|
<!-- Suppress trimming warnings for existing migration and serialization code -->
|
||||||
|
<NoWarn>$(NoWarn);IL2026;IL2072;IL2075</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Remove="Filters/**/*.cs" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\SharedVersion.cs" />
|
<Compile Include="..\SharedVersion.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -45,11 +41,6 @@
|
|||||||
<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" />
|
||||||
@@ -72,7 +63,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CommandLineParser" />
|
<PackageReference Include="CommandLineParser" />
|
||||||
<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" />
|
||||||
@@ -86,7 +76,6 @@
|
|||||||
<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" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -100,12 +89,6 @@
|
|||||||
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.Postgres\Jellyfin.Database.Providers.Postgres.csproj" />
|
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.Postgres\Jellyfin.Database.Providers.Postgres.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="..\sql\**\*.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</Content>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="wwwroot\api-docs\redoc\custom.css">
|
<None Update="wwwroot\api-docs\redoc\custom.css">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
@@ -123,17 +106,21 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- SQL initialization scripts for PostgreSQL -->
|
<!-- SQL initialization scripts for PostgreSQL -->
|
||||||
<None Include="..\scripts\sql\**\*.sql">
|
<None Include="..\sql\**\*.sql">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<Pack>false</Pack>
|
||||||
<Link>sql\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
<Link>sql\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
</None>
|
</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>
|
</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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<NameOfLastUsedPublishProfile>D:\Projects\pgsql-jellyfin\Jellyfin.Server\Properties\PublishProfiles\SelfContained-Linux-x64.pubxml</NameOfLastUsedPublishProfile>
|
<NameOfLastUsedPublishProfile>D:\Projects\pgsql-jellyfin\Jellyfin.Server\Properties\PublishProfiles\MultiInstance-FrameworkDependent.pubxml</NameOfLastUsedPublishProfile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
// 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;
|
||||||
@@ -16,7 +14,6 @@ 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;
|
||||||
@@ -25,6 +22,7 @@ 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;
|
||||||
@@ -99,7 +97,7 @@ internal class JellyfinMigrationService
|
|||||||
|
|
||||||
public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
|
public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
|
||||||
{
|
{
|
||||||
var logger = _startupLogger.Attach(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup");
|
var logger = _startupLogger.With(_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)
|
||||||
@@ -113,25 +111,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);
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
|
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||||
var appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false);
|
|
||||||
var migrationsToSeed = flatApplyMigrations
|
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
|
||||||
|
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 migrationsToSeed)
|
foreach (var item in startupScripts)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Seed migration {Key}-{Name}.", item.Metadata.Key, item.Metadata.Name);
|
logger.LogInformation("Seed migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
|
||||||
await MigrationTracker.InsertAsync(dbContext, item.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false);
|
await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,8 +150,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))
|
||||||
{
|
{
|
||||||
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
|
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||||
var appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false);
|
var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().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)))
|
||||||
@@ -170,10 +168,11 @@ 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.
|
||||||
|
|
||||||
foreach (var item in oldMigrations)
|
var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))));
|
||||||
|
foreach (var item in startupScripts)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Migrate migration {Key}-{Name}.", item.Metadata.Key, item.Metadata.Name);
|
logger.LogInformation("Migrate migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
|
||||||
await MigrationTracker.InsertAsync(dbContext, item.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false);
|
await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("Rename old migration.xml to migration.xml.backup");
|
logger.LogInformation("Rename old migration.xml to migration.xml.backup");
|
||||||
@@ -191,7 +190,7 @@ internal class JellyfinMigrationService
|
|||||||
|
|
||||||
public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
|
public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
|
||||||
{
|
{
|
||||||
var logger = _startupLogger.Attach(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}.");
|
var logger = _startupLogger.With(_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)
|
||||||
@@ -236,24 +235,35 @@ 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 database exists before querying migration history
|
// Ensure the migration history table exists before querying it
|
||||||
// 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 empty database...");
|
logger.LogInformation("Database does not exist, creating...");
|
||||||
await databaseCreator.CreateAsync().ConfigureAwait(false);
|
await databaseCreator.CreateAsync().ConfigureAwait(false);
|
||||||
logger.LogInformation("Empty database created successfully");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
|
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||||
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.Contains(e.BuildCodeMigrationId()))
|
.Where(e => appliedMigrations.All(f => f.MigrationId != 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, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations];
|
(string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = [];
|
||||||
|
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();
|
||||||
|
|
||||||
@@ -374,15 +384,16 @@ internal class JellyfinMigrationService
|
|||||||
{
|
{
|
||||||
logger.LogInformation("Prepare system for possible migrations");
|
logger.LogInformation("Prepare system for possible migrations");
|
||||||
JellyfinMigrationBackupAttribute backupInstruction;
|
JellyfinMigrationBackupAttribute backupInstruction;
|
||||||
IReadOnlyList<string> appliedMigrations;
|
IReadOnlyList<HistoryRow> 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))
|
||||||
{
|
{
|
||||||
await MigrationTracker.EnsureTableExistsAsync(dbContext).ConfigureAwait(false);
|
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||||
appliedMigrations = await MigrationTracker.GetAppliedMigrationIdsAsync(dbContext).ConfigureAwait(false);
|
var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
|
||||||
|
appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
||||||
backupInstruction = new JellyfinMigrationBackupAttribute()
|
backupInstruction = new JellyfinMigrationBackupAttribute()
|
||||||
{
|
{
|
||||||
JellyfinDb = false // Schema migrations are disabled; no EF schema migrations are ever pending
|
JellyfinDb = migrationsAssembly.Migrations.Any(f => appliedMigrations.All(e => e.MigrationId != f.Key))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +402,7 @@ internal class JellyfinMigrationService
|
|||||||
bool isSqliteProvider = false;
|
bool isSqliteProvider = false;
|
||||||
|
|
||||||
backupInstruction = Migrations.SelectMany(e => e)
|
backupInstruction = Migrations.SelectMany(e => e)
|
||||||
.Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId()))
|
.Where(e => appliedMigrations.All(f => f.MigrationId != 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)
|
||||||
@@ -483,8 +494,27 @@ internal class JellyfinMigrationService
|
|||||||
{
|
{
|
||||||
await _codeMigration.Perform(_serviceProvider, logger, CancellationToken.None).ConfigureAwait(false);
|
await _codeMigration.Perform(_serviceProvider, logger, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
await MigrationTracker.InsertAsync(_dbContext, _codeMigration.BuildCodeMigrationId(), GetJellyfinVersion()).ConfigureAwait(false);
|
var historyRepository = _dbContext.GetService<IHistoryRepository>();
|
||||||
|
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.Attach(logger);
|
_logger = startupLogger.With(logger);
|
||||||
_dbProvider = dbProvider;
|
_dbProvider = dbProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,36 +64,30 @@ 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();
|
||||||
// NpgsqlRetryingExecutionStrategy requires all operations inside a user-initiated
|
do
|
||||||
// transaction to be wrapped with CreateExecutionStrategy().Execute(...).
|
|
||||||
context.Database.CreateExecutionStrategy().Execute(() =>
|
|
||||||
{
|
{
|
||||||
using var transaction = context.Database.BeginTransaction();
|
var results = baseQuery.Skip(offset).Take(Limit).Select(b => new Tuple<Guid, string?>(b.Id, b.Path)).ToList();
|
||||||
do
|
foreach (var result in results)
|
||||||
{
|
{
|
||||||
var results = baseQuery.Skip(offset).Take(Limit).Select(b => new Tuple<Guid, string?>(b.Id, b.Path)).ToList();
|
if (TryGetKeyframeData(result.Item1, result.Item2, out var data))
|
||||||
foreach (var result in results)
|
|
||||||
{
|
{
|
||||||
if (TryGetKeyframeData(result.Item1, result.Item2, out var data))
|
itemCount++;
|
||||||
{
|
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,8 +3,6 @@
|
|||||||
// </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;
|
||||||
@@ -97,7 +95,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();
|
||||||
@@ -598,16 +596,19 @@ 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 = streamIndex,
|
StreamIndex = reader.GetInt32(1),
|
||||||
StreamType = streamType,
|
StreamType = Enum.Parse<MediaStreamTypeEntity>(reader.GetString(2)),
|
||||||
Item = null!,
|
Item = null!,
|
||||||
ItemId = itemId,
|
ItemId = reader.GetGuid(0),
|
||||||
|
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))
|
||||||
@@ -620,30 +621,92 @@ 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;
|
||||||
@@ -654,6 +717,11 @@ 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;
|
||||||
@@ -674,151 +742,68 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
item.CodecTimeBase = codecTimeBase;
|
item.CodecTimeBase = codecTimeBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamType == MediaStreamTypeEntity.Audio)
|
if (reader.TryGetString(32, out var colorPrimaries))
|
||||||
{
|
{
|
||||||
var audio = new AudioStreamDetails
|
item.ColorPrimaries = colorPrimaries;
|
||||||
{
|
|
||||||
ItemId = itemId,
|
|
||||||
StreamIndex = streamIndex,
|
|
||||||
};
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
if (reader.TryGetString(33, out var colorSpace))
|
||||||
{
|
{
|
||||||
var video = new VideoStreamDetails
|
item.ColorSpace = colorSpace;
|
||||||
{
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,12 +866,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryReadDateTime(index++, out var startDate))
|
if (reader.TryReadDateTime(index++, out var startDate))
|
||||||
{
|
{
|
||||||
// entity.StartDate = startDate; // Property removed in new schema
|
entity.StartDate = startDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryReadDateTime(index++, out var endDate))
|
if (reader.TryReadDateTime(index++, out var endDate))
|
||||||
{
|
{
|
||||||
// entity.EndDate = endDate; // Property removed in new schema
|
entity.EndDate = endDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetGuid(index++, out var guid))
|
if (reader.TryGetGuid(index++, out var guid))
|
||||||
@@ -906,7 +891,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var episodeTitle))
|
if (reader.TryGetString(index++, out var episodeTitle))
|
||||||
{
|
{
|
||||||
// entity.EpisodeTitle = episodeTitle; // Property removed in new schema
|
entity.EpisodeTitle = episodeTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetBoolean(index++, out var isRepeat))
|
if (reader.TryGetBoolean(index++, out var isRepeat))
|
||||||
@@ -1036,12 +1021,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; // Property removed in new schema
|
entity.Audio = audioType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var serviceName))
|
if (reader.TryGetString(index++, out var serviceName))
|
||||||
{
|
{
|
||||||
// entity.ExternalServiceId = serviceName; // Property removed in new schema
|
entity.ExternalServiceId = serviceName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetBoolean(index++, out var isInMixedFolder))
|
if (reader.TryGetBoolean(index++, out var isInMixedFolder))
|
||||||
@@ -1105,17 +1090,17 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var album))
|
if (reader.TryGetString(index++, out var album))
|
||||||
{
|
{
|
||||||
// entity.Album = album; // Property removed in new schema
|
entity.Album = album;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetSingle(index++, out var lUFS))
|
if (reader.TryGetSingle(index++, out var lUFS))
|
||||||
{
|
{
|
||||||
// entity.LUFS = lUFS; // Property removed in new schema
|
entity.LUFS = lUFS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetSingle(index++, out var normalizationGain))
|
if (reader.TryGetSingle(index++, out var normalizationGain))
|
||||||
{
|
{
|
||||||
// entity.NormalizationGain = normalizationGain; // Property removed in new schema
|
entity.NormalizationGain = normalizationGain;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetSingle(index++, out var criticRating))
|
if (reader.TryGetSingle(index++, out var criticRating))
|
||||||
@@ -1130,7 +1115,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var seriesName))
|
if (reader.TryGetString(index++, out var seriesName))
|
||||||
{
|
{
|
||||||
// entity.SeriesName = seriesName; // Property removed in new schema
|
entity.SeriesName = seriesName;
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDataKeys = new List<string>();
|
var userDataKeys = new List<string>();
|
||||||
@@ -1141,17 +1126,17 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var seasonName))
|
if (reader.TryGetString(index++, out var seasonName))
|
||||||
{
|
{
|
||||||
// entity.SeasonName = seasonName; // Property removed in new schema
|
entity.SeasonName = seasonName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetGuid(index++, out var seasonId))
|
if (reader.TryGetGuid(index++, out var seasonId))
|
||||||
{
|
{
|
||||||
// entity.SeasonId = seasonId; // Property removed in new schema
|
entity.SeasonId = seasonId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetGuid(index++, out var seriesId))
|
if (reader.TryGetGuid(index++, out var seriesId))
|
||||||
{
|
{
|
||||||
// entity.SeriesId = seriesId; // Property removed in new schema
|
entity.SeriesId = seriesId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var presentationUniqueKey))
|
if (reader.TryGetString(index++, out var presentationUniqueKey))
|
||||||
@@ -1166,7 +1151,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var externalSeriesId))
|
if (reader.TryGetString(index++, out var externalSeriesId))
|
||||||
{
|
{
|
||||||
// entity.ExternalSeriesId = externalSeriesId; // Property removed in new schema
|
entity.ExternalSeriesId = externalSeriesId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var tagLine))
|
if (reader.TryGetString(index++, out var tagLine))
|
||||||
@@ -1214,12 +1199,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var artists))
|
if (reader.TryGetString(index++, out var artists))
|
||||||
{
|
{
|
||||||
// entity.Artists = artists; // Property removed in new schema
|
entity.Artists = artists;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var albumArtists))
|
if (reader.TryGetString(index++, out var albumArtists))
|
||||||
{
|
{
|
||||||
// entity.AlbumArtists = albumArtists; // Property removed in new schema
|
entity.AlbumArtists = albumArtists;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var externalId))
|
if (reader.TryGetString(index++, out var externalId))
|
||||||
@@ -1229,12 +1214,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
if (reader.TryGetString(index++, out var seriesPresentationUniqueKey))
|
if (reader.TryGetString(index++, out var seriesPresentationUniqueKey))
|
||||||
{
|
{
|
||||||
// entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; // Property removed in new schema
|
entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var showId))
|
if (reader.TryGetString(index++, out var showId))
|
||||||
{
|
{
|
||||||
// entity.ShowId = showId; // Property removed in new schema
|
entity.ShowId = showId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var ownerId))
|
if (reader.TryGetString(index++, out var ownerId))
|
||||||
|
|||||||
@@ -40,43 +40,37 @@ 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();
|
||||||
|
|
||||||
// NpgsqlRetryingExecutionStrategy requires all operations inside a user-initiated
|
// Materialize the ratings list first to avoid "command in progress" error
|
||||||
// transaction to be wrapped with CreateExecutionStrategy().Execute(...).
|
var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct().ToList();
|
||||||
context.Database.CreateExecutionStrategy().Execute(() =>
|
|
||||||
|
foreach (var rating in ratings)
|
||||||
{
|
{
|
||||||
using var transaction = context.Database.BeginTransaction();
|
if (string.IsNullOrEmpty(rating))
|
||||||
|
|
||||||
// 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
|
||||||
int? value = null;
|
.Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
|
||||||
context.BaseItems
|
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value));
|
||||||
.Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
|
context.BaseItems
|
||||||
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value));
|
.Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
|
||||||
context.BaseItems
|
.ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value));
|
||||||
.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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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();
|
transaction.Commit();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// <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.Attach(logger);
|
_logger = startupLogger.With(logger);
|
||||||
_pathManager = pathManager;
|
_pathManager = pathManager;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
_dbProvider = dbProvider;
|
_dbProvider = dbProvider;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// <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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// <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.Attach(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
|
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
|
||||||
|
|
||||||
StartupHelpers.PerformStaticInitialization();
|
StartupHelpers.PerformStaticInitialization();
|
||||||
|
|
||||||
@@ -290,6 +290,11 @@ namespace Jellyfin.Server
|
|||||||
_migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service");
|
_migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service");
|
||||||
var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer());
|
var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer());
|
||||||
startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]);
|
startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]);
|
||||||
|
|
||||||
|
// Force load database configuration from disk BEFORE initializing DbContext
|
||||||
|
// This ensures database.xml is read from the configured config directory
|
||||||
|
_ = startupConfigurationManager.GetConfiguration("database");
|
||||||
|
|
||||||
var migrationStartupServiceProvider = new ServiceCollection()
|
var migrationStartupServiceProvider = new ServiceCollection()
|
||||||
.AddLogging(d => d.AddSerilog())
|
.AddLogging(d => d.AddSerilog())
|
||||||
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
|
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
|
||||||
|
|||||||
@@ -32,32 +32,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "Async",
|
|
||||||
"Args": {
|
|
||||||
"configure": [
|
|
||||||
{
|
|
||||||
"Name": "File",
|
|
||||||
"Args": {
|
|
||||||
"path": "%JELLYFIN_LOG_DIR%//sql_.log",
|
|
||||||
"rollingInterval": "Day",
|
|
||||||
"retainedFileCountLimit": 3,
|
|
||||||
"rollOnFileSizeLimit": true,
|
|
||||||
"fileSizeLimitBytes": 100000000,
|
|
||||||
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Filter": [
|
|
||||||
{
|
|
||||||
"Name": "ByIncludingOnly",
|
|
||||||
"Args": {
|
|
||||||
"expression": "SourceContext = 'Microsoft.EntityFrameworkCore.Database.Command'"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Enrich": [ "FromLogContext", "WithThreadId" ]
|
"Enrich": [ "FromLogContext", "WithThreadId" ]
|
||||||
|
|||||||
@@ -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 Attach(ILogger logger);
|
IStartupLogger With(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> Attach<TCategory>(ILogger logger);
|
IStartupLogger<TCategory> With<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> Attach(ILogger logger);
|
new IStartupLogger<TCategory> With(ILogger logger);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens a new Group logger within the parent logger.
|
/// Opens a new Group logger within the parent logger.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ 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>
|
||||||
@@ -19,7 +21,6 @@ public class StartupLogger : IStartupLogger
|
|||||||
public StartupLogger(ILogger logger)
|
public StartupLogger(ILogger logger)
|
||||||
{
|
{
|
||||||
BaseLogger = logger;
|
BaseLogger = logger;
|
||||||
Topic = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -29,13 +30,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 { get; private set; }
|
public StartupLogTopic? Topic => _topic;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or Sets the underlying base logger.
|
/// Gets or Sets the underlying base logger.
|
||||||
@@ -49,13 +50,13 @@ public class StartupLogger : IStartupLogger
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public IStartupLogger Attach(ILogger logger)
|
public IStartupLogger With(ILogger logger)
|
||||||
{
|
{
|
||||||
return new StartupLogger(logger, Topic);
|
return new StartupLogger(logger, Topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public IStartupLogger<TCategory> Attach<TCategory>(ILogger logger)
|
public IStartupLogger<TCategory> With<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>.Attach(ILogger logger)
|
IStartupLogger<TCategory> IStartupLogger<TCategory>.With(ILogger logger)
|
||||||
{
|
{
|
||||||
return new StartupLogger<TCategory>(logger, Topic);
|
return new StartupLogger<TCategory>(logger, Topic);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-21
@@ -85,25 +85,18 @@ 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()
|
||||||
HttpMessageHandler CreateEyeballsHttpClientHandler(IServiceProvider serviceProvider)
|
|
||||||
{
|
{
|
||||||
return new SocketsHttpHandler()
|
AutomaticDecompression = DecompressionMethods.All,
|
||||||
{
|
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8,
|
||||||
AutomaticDecompression = DecompressionMethods.All,
|
ConnectCallback = HttpClientExtension.OnConnect
|
||||||
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8,
|
};
|
||||||
ConnectCallback = HttpClientExtension.OnConnect
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
HttpMessageHandler CreateDefaultHttpClientHandler(IServiceProvider serviceProvider)
|
Func<IServiceProvider, HttpMessageHandler> defaultHttpClientHandlerDelegate = (_) => new SocketsHttpHandler()
|
||||||
{
|
{
|
||||||
return new SocketsHttpHandler()
|
AutomaticDecompression = DecompressionMethods.All,
|
||||||
{
|
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8
|
||||||
AutomaticDecompression = DecompressionMethods.All,
|
};
|
||||||
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
services.AddHttpClient(NamedClient.Default, c =>
|
services.AddHttpClient(NamedClient.Default, c =>
|
||||||
{
|
{
|
||||||
@@ -112,7 +105,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(CreateEyeballsHttpClientHandler);
|
.ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate);
|
||||||
|
|
||||||
services.AddHttpClient(NamedClient.MusicBrainz, c =>
|
services.AddHttpClient(NamedClient.MusicBrainz, c =>
|
||||||
{
|
{
|
||||||
@@ -121,7 +114,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(CreateEyeballsHttpClientHandler);
|
.ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate);
|
||||||
|
|
||||||
services.AddHttpClient(NamedClient.DirectIp, c =>
|
services.AddHttpClient(NamedClient.DirectIp, c =>
|
||||||
{
|
{
|
||||||
@@ -130,7 +123,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(CreateDefaultHttpClientHandler);
|
.ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate);
|
||||||
|
|
||||||
services.AddHealthChecks()
|
services.AddHealthChecks()
|
||||||
.AddCheck<DbContextFactoryHealthCheck<JellyfinDbContext>>(nameof(JellyfinDbContext));
|
.AddCheck<DbContextFactoryHealthCheck<JellyfinDbContext>>(nameof(JellyfinDbContext));
|
||||||
@@ -175,8 +168,6 @@ namespace Jellyfin.Server
|
|||||||
|
|
||||||
mainApp.UseWebSockets();
|
mainApp.UseWebSockets();
|
||||||
|
|
||||||
mainApp.UseWebSocketAuthentication();
|
|
||||||
|
|
||||||
mainApp.UseResponseCompression();
|
mainApp.UseResponseCompression();
|
||||||
|
|
||||||
mainApp.UseCors();
|
mainApp.UseCors();
|
||||||
|
|||||||
@@ -1,407 +0,0 @@
|
|||||||
#!/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/scripts/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
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
#!/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 ""
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
#Requires -RunAsAdministrator
|
|
||||||
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Sets up automated weekly performance monitoring via Windows Task Scheduler
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
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
|
|
||||||
Remove the scheduled task
|
|
||||||
|
|
||||||
.PARAMETER TaskTime
|
|
||||||
Time to run the task (default: 6am)
|
|
||||||
|
|
||||||
.PARAMETER DayOfWeek
|
|
||||||
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
|
|
||||||
.\Setup-Weekly-Monitor.ps1
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\Setup-Weekly-Monitor.ps1 -TaskTime "3am" -DayOfWeek Sunday
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\Setup-Weekly-Monitor.ps1 -EnableAutoVacuumAnalyze -DeadTupleThreshold 10000 -DeadTuplePercentThreshold 20
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\Setup-Weekly-Monitor.ps1 -Uninstall
|
|
||||||
#>
|
|
||||||
|
|
||||||
[CmdletBinding()]
|
|
||||||
param (
|
|
||||||
[Parameter()]
|
|
||||||
[switch]$Uninstall,
|
|
||||||
|
|
||||||
[Parameter()]
|
|
||||||
[string]$TaskTime = "6am",
|
|
||||||
|
|
||||||
[Parameter()]
|
|
||||||
[ValidateSet('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday')]
|
|
||||||
[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"
|
|
||||||
|
|
||||||
$TaskName = "Jellyfin Database Weekly Monitor"
|
|
||||||
$ScriptRoot = Split-Path -Parent $PSCommandPath
|
|
||||||
$MonitorScript = Join-Path $ScriptRoot "Weekly-Performance-Monitor.ps1"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Functions
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
function Write-ColorOutput {
|
|
||||||
param(
|
|
||||||
[string]$Message,
|
|
||||||
[ConsoleColor]$Color = 'White'
|
|
||||||
)
|
|
||||||
Write-Host $Message -ForegroundColor $Color
|
|
||||||
}
|
|
||||||
|
|
||||||
function Test-IsAdmin {
|
|
||||||
$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
|
||||||
return $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Main
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
Write-ColorOutput "`n========================================" -Color Cyan
|
|
||||||
Write-ColorOutput "Jellyfin DB Monitor Setup" -Color Cyan
|
|
||||||
Write-ColorOutput "========================================`n" -Color Cyan
|
|
||||||
|
|
||||||
# Check if running as administrator
|
|
||||||
if (-not (Test-IsAdmin)) {
|
|
||||||
Write-ColorOutput "❌ This script must be run as Administrator" -Color Red
|
|
||||||
Write-ColorOutput " Right-click PowerShell and select 'Run as Administrator'" -Color Yellow
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Uninstall if requested
|
|
||||||
if ($Uninstall) {
|
|
||||||
Write-ColorOutput "Removing scheduled task..." -Color Yellow
|
|
||||||
|
|
||||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
if ($task) {
|
|
||||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
|
||||||
Write-ColorOutput "✅ Scheduled task removed successfully" -Color Green
|
|
||||||
} else {
|
|
||||||
Write-ColorOutput "⚠️ Scheduled task not found" -Color Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if monitor script exists
|
|
||||||
if (-not (Test-Path $MonitorScript)) {
|
|
||||||
Write-ColorOutput "❌ Monitor script not found: $MonitorScript" -Color Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-ColorOutput "Configuration:" -Color Cyan
|
|
||||||
Write-ColorOutput " Task Name: $TaskName" -Color White
|
|
||||||
Write-ColorOutput " Script: $MonitorScript" -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 ""
|
|
||||||
|
|
||||||
# Remove existing task if present
|
|
||||||
$existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
||||||
if ($existingTask) {
|
|
||||||
Write-ColorOutput "⚠️ Existing task found. Removing..." -Color Yellow
|
|
||||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create scheduled task action
|
|
||||||
Write-ColorOutput "Creating scheduled task..." -Color Cyan
|
|
||||||
|
|
||||||
$scriptArguments = "-NoProfile -ExecutionPolicy Bypass -File `"$MonitorScript`""
|
|
||||||
if ($EnableAutoVacuumAnalyze) {
|
|
||||||
$scriptArguments += " -AutoVacuumAnalyze -DeadTupleThreshold $DeadTupleThreshold -DeadTuplePercentThreshold $DeadTuplePercentThreshold"
|
|
||||||
}
|
|
||||||
|
|
||||||
$action = New-ScheduledTaskAction `
|
|
||||||
-Execute "PowerShell.exe" `
|
|
||||||
-Argument $scriptArguments
|
|
||||||
|
|
||||||
# Create trigger
|
|
||||||
$trigger = New-ScheduledTaskTrigger `
|
|
||||||
-Weekly `
|
|
||||||
-DaysOfWeek $DayOfWeek `
|
|
||||||
-At $TaskTime
|
|
||||||
|
|
||||||
# Task settings
|
|
||||||
$settings = New-ScheduledTaskSettingsSet `
|
|
||||||
-AllowStartIfOnBatteries `
|
|
||||||
-DontStopIfGoingOnBatteries `
|
|
||||||
-StartWhenAvailable `
|
|
||||||
-RunOnlyIfNetworkAvailable `
|
|
||||||
-ExecutionTimeLimit (New-TimeSpan -Hours 2)
|
|
||||||
|
|
||||||
# Task principal (run as current user)
|
|
||||||
$principal = New-ScheduledTaskPrincipal `
|
|
||||||
-UserId $env:USERNAME `
|
|
||||||
-LogonType S4U `
|
|
||||||
-RunLevel Highest
|
|
||||||
|
|
||||||
# Register the task
|
|
||||||
try {
|
|
||||||
Register-ScheduledTask `
|
|
||||||
-TaskName $TaskName `
|
|
||||||
-Action $action `
|
|
||||||
-Trigger $trigger `
|
|
||||||
-Settings $settings `
|
|
||||||
-Principal $principal `
|
|
||||||
-Description "Automated weekly performance monitoring for Jellyfin PostgreSQL database" | Out-Null
|
|
||||||
|
|
||||||
Write-ColorOutput "✅ Scheduled task created successfully!" -Color Green
|
|
||||||
Write-ColorOutput ""
|
|
||||||
|
|
||||||
# Verify task
|
|
||||||
$task = Get-ScheduledTask -TaskName $TaskName
|
|
||||||
$nextRun = $task.Triggers[0].StartBoundary
|
|
||||||
|
|
||||||
Write-ColorOutput "Task Details:" -Color Cyan
|
|
||||||
Write-ColorOutput " Status: $($task.State)" -Color White
|
|
||||||
Write-ColorOutput " Next Run: $nextRun" -Color White
|
|
||||||
Write-ColorOutput ""
|
|
||||||
|
|
||||||
# Test run option
|
|
||||||
Write-ColorOutput "Would you like to test the task now? (y/n): " -Color Yellow -NoNewline
|
|
||||||
$response = Read-Host
|
|
||||||
|
|
||||||
if ($response -eq 'y' -or $response -eq 'Y') {
|
|
||||||
Write-ColorOutput "`nRunning test..." -Color Cyan
|
|
||||||
Start-ScheduledTask -TaskName $TaskName
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
|
|
||||||
$taskInfo = Get-ScheduledTask -TaskName $TaskName | Get-ScheduledTaskInfo
|
|
||||||
Write-ColorOutput "Last Run Time: $($taskInfo.LastRunTime)" -Color White
|
|
||||||
Write-ColorOutput "Last Result: $($taskInfo.LastTaskResult)" -Color White
|
|
||||||
|
|
||||||
if ($taskInfo.LastTaskResult -eq 0) {
|
|
||||||
Write-ColorOutput "✅ Test run completed successfully!" -Color Green
|
|
||||||
} else {
|
|
||||||
Write-ColorOutput "⚠️ Test run completed with code: $($taskInfo.LastTaskResult)" -Color Yellow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
Write-ColorOutput "❌ Failed to create scheduled task: $_" -Color Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-ColorOutput "`n========================================" -Color Green
|
|
||||||
Write-ColorOutput "Setup Complete!" -Color Green
|
|
||||||
Write-ColorOutput "========================================" -Color Green
|
|
||||||
Write-ColorOutput ""
|
|
||||||
Write-ColorOutput "Monitoring is now automated!" -Color Cyan
|
|
||||||
Write-ColorOutput ""
|
|
||||||
Write-ColorOutput "To manage the task:" -Color White
|
|
||||||
Write-ColorOutput " View: Get-ScheduledTask -TaskName '$TaskName'" -Color Gray
|
|
||||||
Write-ColorOutput " Run now: Start-ScheduledTask -TaskName '$TaskName'" -Color Gray
|
|
||||||
Write-ColorOutput " Disable: Disable-ScheduledTask -TaskName '$TaskName'" -Color Gray
|
|
||||||
Write-ColorOutput " Remove: .\Setup-Weekly-Monitor.ps1 -Uninstall" -Color Gray
|
|
||||||
Write-ColorOutput ""
|
|
||||||
Write-ColorOutput "Reports will be saved to: reports\weekly" -Color Yellow
|
|
||||||
Write-ColorOutput "========================================`n" -Color Green
|
|
||||||
@@ -264,13 +264,11 @@ BEGIN
|
|||||||
ORDER BY mean_exec_time DESC
|
ORDER BY mean_exec_time DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
LOOP
|
LOOP
|
||||||
RAISE NOTICE 'Calls: %, Avg: %ms, Max: %ms, Total: %ms, %%: %, Query: %',
|
RAISE NOTICE 'Calls: %, Avg: %ms, Max: %ms, Total: %ms, Percent: %%',
|
||||||
query_rec.calls,
|
query_rec.calls,
|
||||||
query_rec.avg_time_ms,
|
query_rec.avg_time_ms,
|
||||||
query_rec.max_time_ms,
|
query_rec.max_time_ms,
|
||||||
query_rec.total_time_ms,
|
query_rec.total_time_ms;
|
||||||
query_rec.percent_total,
|
|
||||||
query_rec.query_preview;
|
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
@@ -9,6 +9,6 @@
|
|||||||
"CacheDir": "C:/ProgramData/jellyfin/cache",
|
"CacheDir": "C:/ProgramData/jellyfin/cache",
|
||||||
"LogDir": "C:/ProgramData/jellyfin/log",
|
"LogDir": "C:/ProgramData/jellyfin/log",
|
||||||
"TempDir": "C:\\Users\\wjones\\AppData\\Local\\Temp\\jellyfin",
|
"TempDir": "C:\\Users\\wjones\\AppData\\Local\\Temp\\jellyfin",
|
||||||
"WebDir": "C:/ProgramData/jellyfin/wwwroot"
|
"WebDir": "wwwroot"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-2
@@ -1,4 +1,4 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 18
|
# Visual Studio Version 18
|
||||||
VisualStudioVersion = 18.0.11222.15
|
VisualStudioVersion = 18.0.11222.15
|
||||||
@@ -93,6 +93,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyf
|
|||||||
src\Jellyfin.Database\readme.md = src\Jellyfin.Database\readme.md
|
src\Jellyfin.Database\readme.md = src\Jellyfin.Database\readme.md
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}"
|
||||||
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
|
||||||
@@ -553,6 +555,18 @@ Global
|
|||||||
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x64.Build.0 = Release|Any CPU
|
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x86.ActiveCfg = Release|Any CPU
|
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x86.Build.0 = Release|Any CPU
|
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
@@ -618,6 +632,7 @@ Global
|
|||||||
{C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
{C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||||
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||||
{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||||
|
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||||
{E1A314DC-837D-4472-AC15-EC08947C55B7} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
{E1A314DC-837D-4472-AC15-EC08947C55B7} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||||
@@ -644,4 +659,3 @@ Global
|
|||||||
$2.DirectoryNamespaceAssociation = PrefixedHierarchical
|
$2.DirectoryNamespaceAssociation = PrefixedHierarchical
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
<VersionPrefix>10.12.0</VersionPrefix>
|
<VersionPrefix>10.12.0</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
<NoWarn>$(NoWarn);CA1054;CA1055;CA1308;CA1031;CA1032;CA2007;CA1822;CA1848;CA1303;CA1508;CA1805;CA1056;CA1062;CA1515;CA1812;CA1849;CA1860;CA1867;CA1517;CA1865;CA1869;CA1716;CA2101;SA1127</NoWarn>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -60,8 +59,4 @@
|
|||||||
</AssemblyAttribute>
|
</AssemblyAttribute>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ using Jellyfin.Database.Implementations.Entities;
|
|||||||
using Jellyfin.Extensions.Json;
|
using Jellyfin.Extensions.Json;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Persistence;
|
|
||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
using MediaBrowser.Model.Configuration;
|
using MediaBrowser.Model.Configuration;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
@@ -70,8 +69,6 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
public static IServerApplicationHost ApplicationHost { get; set; }
|
public static IServerApplicationHost ApplicationHost { get; set; }
|
||||||
|
|
||||||
public static ILibraryOptionsRepository LibraryOptionsRepository { get; set; }
|
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public override bool SupportsPlayedStatus => false;
|
public override bool SupportsPlayedStatus => false;
|
||||||
|
|
||||||
@@ -115,24 +112,9 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
private static LibraryOptions LoadLibraryOptions(string path)
|
private static LibraryOptions LoadLibraryOptions(string path)
|
||||||
{
|
{
|
||||||
if (LibraryOptionsRepository is not null)
|
|
||||||
{
|
|
||||||
var result = LibraryOptionsRepository.GetLibraryOptions(path);
|
|
||||||
if (result is not null)
|
|
||||||
{
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var xmlPath = GetLibraryOptionsPath(path);
|
|
||||||
if (!File.Exists(xmlPath))
|
|
||||||
{
|
|
||||||
return new LibraryOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (XmlSerializer.DeserializeFromFile(typeof(LibraryOptions), xmlPath) is not LibraryOptions result)
|
if (XmlSerializer.DeserializeFromFile(typeof(LibraryOptions), GetLibraryOptionsPath(path)) is not LibraryOptions result)
|
||||||
{
|
{
|
||||||
return new LibraryOptions();
|
return new LibraryOptions();
|
||||||
}
|
}
|
||||||
@@ -145,8 +127,6 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LibraryOptionsRepository?.SaveLibraryOptions(path, result);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
catch (FileNotFoundException)
|
catch (FileNotFoundException)
|
||||||
@@ -182,8 +162,6 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
{
|
{
|
||||||
_libraryOptions[path] = options;
|
_libraryOptions[path] = options;
|
||||||
|
|
||||||
LibraryOptionsRepository?.SaveLibraryOptions(path, options);
|
|
||||||
|
|
||||||
var clone = JsonSerializer.Deserialize<LibraryOptions>(JsonSerializer.SerializeToUtf8Bytes(options, _jsonOptions), _jsonOptions);
|
var clone = JsonSerializer.Deserialize<LibraryOptions>(JsonSerializer.SerializeToUtf8Bytes(options, _jsonOptions), _jsonOptions);
|
||||||
foreach (var mediaPath in clone.PathInfos)
|
foreach (var mediaPath in clone.PathInfos)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -420,7 +420,6 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
// Create a list for our validated children
|
// Create a list for our validated children
|
||||||
var newItems = new List<BaseItem>();
|
var newItems = new List<BaseItem>();
|
||||||
var changedItems = new List<BaseItem>();
|
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -437,7 +436,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None)
|
if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None)
|
||||||
{
|
{
|
||||||
changedItems.Add(currentChild);
|
await currentChild.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -485,11 +484,6 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
LibraryManager.CreateItems(newItems, this, cancellationToken);
|
LibraryManager.CreateItems(newItems, this, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedItems.Count > 0)
|
|
||||||
{
|
|
||||||
await LibraryManager.UpdateItemsAsync(changedItems, this, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// After removing items, reattach any detached user data to remaining children
|
// After removing items, reattach any detached user data to remaining children
|
||||||
// that share the same user data keys (eg. same episode replaced with a new file).
|
// that share the same user data keys (eg. same episode replaced with a new file).
|
||||||
if (actuallyRemoved.Count > 0)
|
if (actuallyRemoved.Count > 0)
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
query.AncestorWithPresentationUniqueKey = null;
|
query.AncestorWithPresentationUniqueKey = null;
|
||||||
query.SeriesPresentationUniqueKey = seriesKey;
|
query.SeriesPresentationUniqueKey = seriesKey;
|
||||||
query.IncludeItemTypes = new[] { BaseItemKind.Season };
|
query.IncludeItemTypes = new[] { BaseItemKind.Season };
|
||||||
query.OrderBy = new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending) };
|
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||||
|
|
||||||
if (user is not null && !user.DisplayMissingEpisodes)
|
if (user is not null && !user.DisplayMissingEpisodes)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -204,12 +204,6 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
query.IncludeItemTypes = new[] { BaseItemKind.Movie };
|
query.IncludeItemTypes = new[] { BaseItemKind.Movie };
|
||||||
|
|
||||||
// Ensure alphabetical ordering if not specified
|
|
||||||
if (query.OrderBy.Count == 0)
|
|
||||||
{
|
|
||||||
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return _libraryManager.GetItemsResult(query);
|
return _libraryManager.GetItemsResult(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,12 +372,6 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
query.IncludeItemTypes = new[] { BaseItemKind.Series };
|
query.IncludeItemTypes = new[] { BaseItemKind.Series };
|
||||||
|
|
||||||
// Ensure alphabetical ordering if not specified
|
|
||||||
if (query.OrderBy.Count == 0)
|
|
||||||
{
|
|
||||||
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return _libraryManager.GetItemsResult(query);
|
return _libraryManager.GetItemsResult(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ namespace MediaBrowser.Controller.Extensions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string UnixSocketPermissionsKey = "kestrel:socketPermissions";
|
public const string UnixSocketPermissionsKey = "kestrel:socketPermissions";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cache size of the SQL database, see cache_size.
|
||||||
|
/// </summary>
|
||||||
|
public const string SqliteCacheSizeKey = "sqlite:cacheSize";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The key for a setting that indicates whether the application should detect network status change.
|
/// The key for a setting that indicates whether the application should detect network status change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -137,5 +142,13 @@ namespace MediaBrowser.Controller.Extensions
|
|||||||
/// <returns>The unix socket permissions.</returns>
|
/// <returns>The unix socket permissions.</returns>
|
||||||
public static string? GetUnixSocketPermissions(this IConfiguration configuration)
|
public static string? GetUnixSocketPermissions(this IConfiguration configuration)
|
||||||
=> configuration[UnixSocketPermissionsKey];
|
=> configuration[UnixSocketPermissionsKey];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the cache_size from the <see cref="IConfiguration" />.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configuration">The configuration to read the setting from.</param>
|
||||||
|
/// <returns>The sqlite cache size.</returns>
|
||||||
|
public static int? GetSqliteCacheSize(this IConfiguration configuration)
|
||||||
|
=> configuration.GetValue<int?>(SqliteCacheSizeKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
|||||||
if (fanoutConcurrency <= 0)
|
if (fanoutConcurrency <= 0)
|
||||||
{
|
{
|
||||||
// in case the user did not set a limit manually, we can assume he has 3 or more cores as already checked by ShouldForceSequentialOperation.
|
// in case the user did not set a limit manually, we can assume he has 3 or more cores as already checked by ShouldForceSequentialOperation.
|
||||||
return Math.Max(2, Environment.ProcessorCount / 2);
|
return Environment.ProcessorCount - 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fanoutConcurrency;
|
return fanoutConcurrency;
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
<!-- 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>{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}</ProjectGuid>
|
<ProjectGuid>{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}</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>
|
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Controller</PackageId>
|
<PackageId>Jellyfin.Controller</PackageId>
|
||||||
<VersionPrefix>10.12.0</VersionPrefix>
|
<VersionPrefix>10.12.0</VersionPrefix>
|
||||||
@@ -16,7 +14,6 @@
|
|||||||
</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>
|
||||||
|
|
||||||
@@ -24,7 +21,6 @@
|
|||||||
<PackageReference Include="BitFaster.Caching" />
|
<PackageReference Include="BitFaster.Caching" />
|
||||||
<PackageReference Include="Jellyfin.Sdk" />
|
<PackageReference Include="Jellyfin.Sdk" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||||
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -39,7 +35,6 @@
|
|||||||
</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>
|
||||||
@@ -50,7 +45,6 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
// <copyright file="ILibraryOptionsRepository.cs" company="PlaceholderCompany">
|
|
||||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
|
||||||
// </copyright>
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using MediaBrowser.Model.Configuration;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.Persistence;
|
|
||||||
|
|
||||||
public interface ILibraryOptionsRepository
|
|
||||||
{
|
|
||||||
LibraryOptions GetLibraryOptions(string libraryPath);
|
|
||||||
|
|
||||||
void SaveLibraryOptions(string libraryPath, LibraryOptions options);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
|
|
||||||
@@ -31,22 +30,7 @@ namespace MediaBrowser.Controller.Providers
|
|||||||
|
|
||||||
public FileSystemMetadata[] GetFileSystemEntries(string path)
|
public FileSystemMetadata[] GetFileSystemEntries(string path)
|
||||||
{
|
{
|
||||||
if (_cache.TryGetValue(path, out var result))
|
return _cache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem);
|
||||||
{
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
result = _fileSystem.GetFileSystemEntries(path).ToArray();
|
|
||||||
_cache.TryAdd(path, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
catch (DirectoryNotFoundException)
|
|
||||||
{
|
|
||||||
// A folder may be deleted between scans; treat it as empty so refresh logic can continue.
|
|
||||||
return Array.Empty<FileSystemMetadata>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<FileSystemMetadata> GetDirectories(string path)
|
public List<FileSystemMetadata> GetDirectories(string path)
|
||||||
|
|||||||
@@ -178,6 +178,16 @@ namespace MediaBrowser.Controller.Session
|
|||||||
/// <value>The application version.</value>
|
/// <value>The application version.</value>
|
||||||
public string ApplicationVersion { get; set; }
|
public string ApplicationVersion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the instance ID that owns this session.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The instance ID.</value>
|
||||||
|
/// <remarks>
|
||||||
|
/// Used for multi-instance deployments to track which Jellyfin instance
|
||||||
|
/// is managing this session's resources (transcoding, network connections, etc.).
|
||||||
|
/// </remarks>
|
||||||
|
public Guid InstanceId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the session controller.
|
/// Gets or sets the session controller.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
<!-- 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>{7EF9F3E0-697D-42F3-A08F-19DEB5F84392}</ProjectGuid>
|
<ProjectGuid>{7EF9F3E0-697D-42F3-A08F-19DEB5F84392}</ProjectGuid>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -12,7 +11,6 @@
|
|||||||
</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>
|
||||||
@@ -37,8 +35,5 @@
|
|||||||
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
|
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
|
||||||
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
|
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
<!-- 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>{960295EE-4AF4-4440-A525-B4C295B01A61}</ProjectGuid>
|
<ProjectGuid>{960295EE-4AF4-4440-A525-B4C295B01A61}</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>
|
||||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
@@ -29,7 +27,6 @@
|
|||||||
<PackageReference Include="Jellyfin.Sdk" />
|
<PackageReference Include="Jellyfin.Sdk" />
|
||||||
<PackageReference Include="libse" />
|
<PackageReference Include="libse" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||||
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
|
||||||
<PackageReference Include="UTF.Unknown" />
|
<PackageReference Include="UTF.Unknown" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
<!-- 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>{7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}</ProjectGuid>
|
<ProjectGuid>{7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}</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>
|
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Model</PackageId>
|
<PackageId>Jellyfin.Model</PackageId>
|
||||||
<VersionPrefix>10.12.0</VersionPrefix>
|
<VersionPrefix>10.12.0</VersionPrefix>
|
||||||
@@ -16,7 +14,6 @@
|
|||||||
</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>
|
||||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
@@ -27,12 +24,10 @@
|
|||||||
</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>
|
||||||
|
|
||||||
<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>
|
||||||
@@ -43,7 +38,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Jellyfin.Sdk" />
|
<PackageReference Include="Jellyfin.Sdk" />
|
||||||
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
|
||||||
<PackageReference Include="MimeTypes">
|
<PackageReference Include="MimeTypes">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
<!-- 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>{442B5058-DCAF-4263-BB6A-F21E31120A1B}</ProjectGuid>
|
<ProjectGuid>{442B5058-DCAF-4263-BB6A-F21E31120A1B}</ProjectGuid>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -20,8 +19,8 @@
|
|||||||
<PackageReference Include="Jellyfin.Sdk" />
|
<PackageReference Include="Jellyfin.Sdk" />
|
||||||
<PackageReference Include="LrcParser" />
|
<PackageReference Include="LrcParser" />
|
||||||
<PackageReference Include="MetaBrainz.MusicBrainz" />
|
<PackageReference Include="MetaBrainz.MusicBrainz" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||||
<PackageReference Include="Microsoft.Kiota.Abstractions" />
|
|
||||||
<PackageReference Include="Newtonsoft.Json" />
|
<PackageReference Include="Newtonsoft.Json" />
|
||||||
<PackageReference Include="PlaylistsNET" />
|
<PackageReference Include="PlaylistsNET" />
|
||||||
<PackageReference Include="z440.atl.core" />
|
<PackageReference Include="z440.atl.core" />
|
||||||
@@ -29,7 +28,6 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
|||||||
if (ep is not null)
|
if (ep is not null)
|
||||||
{
|
{
|
||||||
seasonNumber = ep.SeasonNumber;
|
seasonNumber = ep.SeasonNumber;
|
||||||
episodeNumber = (int)ep.EpisodeNumber;
|
episodeNumber = ep.EpisodeNumber;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user