aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOption.cs19
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOptions.cs32
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs13
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseLockingBehaviorTypes.cs22
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs7
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj1
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs55
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/IEntityFrameworkCoreLockingBehavior.cs32
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/NoLockBehavior.cs41
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs137
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs296
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ProgressablePartitionReporting.cs55
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs215
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs4
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs15
-rw-r--r--src/Jellyfin.Networking/Manager/NetworkManager.cs47
16 files changed, 963 insertions, 28 deletions
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOption.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOption.cs
new file mode 100644
index 000000000..fcb8f41b3
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOption.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+
+namespace Jellyfin.Database.Implementations.DbConfiguration;
+
+/// <summary>
+/// The custom value option for custom database providers.
+/// </summary>
+public class CustomDatabaseOption
+{
+ /// <summary>
+ /// Gets or sets the key of the value.
+ /// </summary>
+ public required string Key { get; set; }
+
+ /// <summary>
+ /// Gets or sets the value.
+ /// </summary>
+ public required string Value { get; set; }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOptions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOptions.cs
new file mode 100644
index 000000000..e2088704d
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOptions.cs
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace Jellyfin.Database.Implementations.DbConfiguration;
+
+/// <summary>
+/// Defines the options for a custom database connector.
+/// </summary>
+public class CustomDatabaseOptions
+{
+ /// <summary>
+ /// Gets or sets the Plugin name to search for database providers.
+ /// </summary>
+ public required string PluginName { get; set; }
+
+ /// <summary>
+ /// Gets or sets the plugin assembly to search for providers.
+ /// </summary>
+ public required string PluginAssembly { get; set; }
+
+ /// <summary>
+ /// Gets or sets the connection string for the custom database provider.
+ /// </summary>
+ public required string ConnectionString { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of extra options for the custom provider.
+ /// </summary>
+#pragma warning disable CA2227 // Collection properties should be read only
+ public Collection<CustomDatabaseOption> Options { get; set; } = [];
+#pragma warning restore CA2227 // Collection properties should be read only
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs
index b481a106f..bc0cacf3c 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs
@@ -1,3 +1,5 @@
+using System.Collections.Generic;
+
namespace Jellyfin.Database.Implementations.DbConfiguration;
/// <summary>
@@ -9,4 +11,15 @@ public class DatabaseConfigurationOptions
/// Gets or Sets the type of database jellyfin should use.
/// </summary>
public required string DatabaseType { get; set; }
+
+ /// <summary>
+ /// Gets or sets the options required to use a custom database provider.
+ /// </summary>
+ public CustomDatabaseOptions? CustomProviderOptions { get; set; }
+
+ /// <summary>
+ /// Gets or Sets the kind of locking behavior jellyfin should perform. Possible options are "NoLock", "Pessimistic", "Optimistic".
+ /// Defaults to "NoLock".
+ /// </summary>
+ public DatabaseLockingBehaviorTypes LockingBehavior { get; set; }
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseLockingBehaviorTypes.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseLockingBehaviorTypes.cs
new file mode 100644
index 000000000..3b2a55802
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseLockingBehaviorTypes.cs
@@ -0,0 +1,22 @@
+namespace Jellyfin.Database.Implementations.DbConfiguration;
+
+/// <summary>
+/// Defines all possible methods for locking database access for concurrent queries.
+/// </summary>
+public enum DatabaseLockingBehaviorTypes
+{
+ /// <summary>
+ /// Defines that no explicit application level locking for reads and writes should be done and only provider specific locking should be relied on.
+ /// </summary>
+ NoLock = 0,
+
+ /// <summary>
+ /// Defines a behavior that always blocks all reads while any one write is done.
+ /// </summary>
+ Pessimistic = 1,
+
+ /// <summary>
+ /// Defines that all writes should be attempted and when fail should be retried.
+ /// </summary>
+ Optimistic = 2
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
index b0dc98469..6b35810b2 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
@@ -65,6 +65,13 @@ public interface IJellyfinDatabaseProvider
Task RestoreBackupFast(string key, CancellationToken cancellationToken);
/// <summary>
+ /// Deletes a backup that has been previously created by <see cref="MigrationBackupFast(CancellationToken)"/>.
+ /// </summary>
+ /// <param name="key">The key to the backup which should be cleaned up.</param>
+ /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
+ Task DeleteBackup(string key);
+
+ /// <summary>
/// Removes all contents from the database.
/// </summary>
/// <param name="dbContext">The Database context.</param>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
index 356f96fc9..28c4972d2 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
@@ -24,6 +24,7 @@
</PropertyGroup>
<ItemGroup>
+ <PackageReference Include="Polly" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
index 35ad461ec..5163bff8b 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
@@ -1,9 +1,14 @@
using System;
+using System.Data.Common;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Security;
using Jellyfin.Database.Implementations.Interfaces;
+using Jellyfin.Database.Implementations.Locking;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Database.Implementations;
@@ -15,7 +20,8 @@ namespace Jellyfin.Database.Implementations;
/// <param name="options">The database context options.</param>
/// <param name="logger">Logger.</param>
/// <param name="jellyfinDatabaseProvider">The provider for the database engine specific operations.</param>
-public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILogger<JellyfinDbContext> logger, IJellyfinDatabaseProvider jellyfinDatabaseProvider) : DbContext(options)
+/// <param name="entityFrameworkCoreLocking">The locking behavior.</param>
+public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILogger<JellyfinDbContext> logger, IJellyfinDatabaseProvider jellyfinDatabaseProvider, IEntityFrameworkCoreLockingBehavior entityFrameworkCoreLocking) : DbContext(options)
{
/// <summary>
/// Gets the <see cref="DbSet{TEntity}"/> containing the access schedules.
@@ -247,19 +253,41 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
public DbSet<TrackMetadata> TrackMetadata => Set<TrackMetadata>();*/
/// <inheritdoc/>
- public override int SaveChanges()
+ public override async Task<int> SaveChangesAsync(
+ bool acceptAllChangesOnSuccess,
+ CancellationToken cancellationToken = default)
{
- foreach (var saveEntity in ChangeTracker.Entries()
- .Where(e => e.State == EntityState.Modified)
- .Select(entry => entry.Entity)
- .OfType<IHasConcurrencyToken>())
+ HandleConcurrencyToken();
+
+ try
{
- saveEntity.OnSavingChanges();
+ var result = -1;
+ await entityFrameworkCoreLocking.OnSaveChangesAsync(this, async () =>
+ {
+ result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken).ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ return result;
}
+ catch (Exception e)
+ {
+ logger.LogError(e, "Error trying to save changes.");
+ throw;
+ }
+ }
+
+ /// <inheritdoc/>
+ public override int SaveChanges(bool acceptAllChangesOnSuccess) // SaveChanges(bool) is beeing called by SaveChanges() with default to false.
+ {
+ HandleConcurrencyToken();
try
{
- return base.SaveChanges();
+ var result = -1;
+ entityFrameworkCoreLocking.OnSaveChanges(this, () =>
+ {
+ result = base.SaveChanges(acceptAllChangesOnSuccess);
+ });
+ return result;
}
catch (Exception e)
{
@@ -268,6 +296,17 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
}
}
+ private void HandleConcurrencyToken()
+ {
+ foreach (var saveEntity in ChangeTracker.Entries()
+ .Where(e => e.State == EntityState.Modified)
+ .Select(entry => entry.Entity)
+ .OfType<IHasConcurrencyToken>())
+ {
+ saveEntity.OnSavingChanges();
+ }
+ }
+
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/IEntityFrameworkCoreLockingBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/IEntityFrameworkCoreLockingBehavior.cs
new file mode 100644
index 000000000..465c31212
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/IEntityFrameworkCoreLockingBehavior.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jellyfin.Database.Implementations.Locking;
+
+/// <summary>
+/// Defines a jellyfin locking behavior that can be configured.
+/// </summary>
+public interface IEntityFrameworkCoreLockingBehavior
+{
+ /// <summary>
+ /// Provides access to the builder to setup any connection related locking behavior.
+ /// </summary>
+ /// <param name="optionsBuilder">The options builder.</param>
+ void Initialise(DbContextOptionsBuilder optionsBuilder);
+
+ /// <summary>
+ /// Will be invoked when changes should be saved in the current locking behavior.
+ /// </summary>
+ /// <param name="context">The database context invoking the action.</param>
+ /// <param name="saveChanges">Callback for performing the actual save changes.</param>
+ void OnSaveChanges(JellyfinDbContext context, Action saveChanges);
+
+ /// <summary>
+ /// Will be invoked when changes should be saved in the current locking behavior.
+ /// </summary>
+ /// <param name="context">The database context invoking the action.</param>
+ /// <param name="saveChanges">Callback for performing the actual save changes.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ Task OnSaveChangesAsync(JellyfinDbContext context, Func<Task> saveChanges);
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/NoLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/NoLockBehavior.cs
new file mode 100644
index 000000000..3b654f4c4
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/NoLockBehavior.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Database.Implementations.Locking;
+
+/// <summary>
+/// Default lock behavior. Defines no explicit application locking behavior.
+/// </summary>
+public class NoLockBehavior : IEntityFrameworkCoreLockingBehavior
+{
+ private readonly ILogger<NoLockBehavior> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NoLockBehavior"/> class.
+ /// </summary>
+ /// <param name="logger">The Application logger.</param>
+ public NoLockBehavior(ILogger<NoLockBehavior> logger)
+ {
+ _logger = logger;
+ }
+
+ /// <inheritdoc/>
+ public void OnSaveChanges(JellyfinDbContext context, Action saveChanges)
+ {
+ saveChanges();
+ }
+
+ /// <inheritdoc/>
+ public void Initialise(DbContextOptionsBuilder optionsBuilder)
+ {
+ _logger.LogInformation("The database locking mode has been set to: NoLock.");
+ }
+
+ /// <inheritdoc/>
+ public async Task OnSaveChangesAsync(JellyfinDbContext context, Func<Task> saveChanges)
+ {
+ await saveChanges().ConfigureAwait(false);
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
new file mode 100644
index 000000000..9395b2e2d
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Data.Common;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Logging;
+using Polly;
+
+namespace Jellyfin.Database.Implementations.Locking;
+
+/// <summary>
+/// Defines a locking mechanism that will retry any write operation for a few times.
+/// </summary>
+public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
+{
+ private readonly Policy _writePolicy;
+ private readonly AsyncPolicy _writeAsyncPolicy;
+ private readonly ILogger<OptimisticLockBehavior> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="OptimisticLockBehavior"/> class.
+ /// </summary>
+ /// <param name="logger">The application logger.</param>
+ public OptimisticLockBehavior(ILogger<OptimisticLockBehavior> logger)
+ {
+ TimeSpan[] sleepDurations = [
+ TimeSpan.FromMilliseconds(50),
+ TimeSpan.FromMilliseconds(50),
+ TimeSpan.FromMilliseconds(250),
+ TimeSpan.FromMilliseconds(150),
+ TimeSpan.FromMilliseconds(500),
+ TimeSpan.FromMilliseconds(500),
+ TimeSpan.FromSeconds(3)
+ ];
+ _logger = logger;
+ _writePolicy = Policy.HandleInner<Exception>(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)).WaitAndRetry(sleepDurations, RetryHandle);
+ _writeAsyncPolicy = Policy.HandleInner<Exception>(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)).WaitAndRetryAsync(sleepDurations, RetryHandle);
+
+ void RetryHandle(Exception exception, TimeSpan timespan, int retryNo, Context context)
+ {
+ if (retryNo < sleepDurations.Length)
+ {
+ _logger.LogWarning("Operation failed retry {RetryNo}", retryNo);
+ }
+ else
+ {
+ _logger.LogError(exception, "Operation failed retry {RetryNo}", retryNo);
+ }
+ }
+ }
+
+ /// <inheritdoc/>
+ public void Initialise(DbContextOptionsBuilder optionsBuilder)
+ {
+ _logger.LogInformation("The database locking mode has been set to: Optimistic.");
+ optionsBuilder.AddInterceptors(new RetryInterceptor(_writeAsyncPolicy, _writePolicy));
+ optionsBuilder.AddInterceptors(new TransactionLockingInterceptor(_writeAsyncPolicy, _writePolicy));
+ }
+
+ /// <inheritdoc/>
+ public void OnSaveChanges(JellyfinDbContext context, Action saveChanges)
+ {
+ _writePolicy.ExecuteAndCapture(saveChanges);
+ }
+
+ /// <inheritdoc/>
+ public async Task OnSaveChangesAsync(JellyfinDbContext context, Func<Task> saveChanges)
+ {
+ await _writeAsyncPolicy.ExecuteAndCaptureAsync(saveChanges).ConfigureAwait(false);
+ }
+
+ private sealed class TransactionLockingInterceptor : DbTransactionInterceptor
+ {
+ private readonly AsyncPolicy _asyncRetryPolicy;
+ private readonly Policy _retryPolicy;
+
+ public TransactionLockingInterceptor(AsyncPolicy asyncRetryPolicy, Policy retryPolicy)
+ {
+ _asyncRetryPolicy = asyncRetryPolicy;
+ _retryPolicy = retryPolicy;
+ }
+
+ public override InterceptionResult<DbTransaction> TransactionStarting(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult<DbTransaction> result)
+ {
+ return InterceptionResult<DbTransaction>.SuppressWithResult(_retryPolicy.Execute(() => connection.BeginTransaction(eventData.IsolationLevel)));
+ }
+
+ public override async ValueTask<InterceptionResult<DbTransaction>> TransactionStartingAsync(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult<DbTransaction> result, CancellationToken cancellationToken = default)
+ {
+ return InterceptionResult<DbTransaction>.SuppressWithResult(await _asyncRetryPolicy.ExecuteAsync(async () => await connection.BeginTransactionAsync(eventData.IsolationLevel, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false));
+ }
+ }
+
+ private sealed class RetryInterceptor : DbCommandInterceptor
+ {
+ private readonly AsyncPolicy _asyncRetryPolicy;
+ private readonly Policy _retryPolicy;
+
+ public RetryInterceptor(AsyncPolicy asyncRetryPolicy, Policy retryPolicy)
+ {
+ _asyncRetryPolicy = asyncRetryPolicy;
+ _retryPolicy = retryPolicy;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ return InterceptionResult<int>.SuppressWithResult(_retryPolicy.Execute(command.ExecuteNonQuery));
+ }
+
+ public override async ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
+ {
+ return InterceptionResult<int>.SuppressWithResult(await _asyncRetryPolicy.ExecuteAsync(async () => await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false));
+ }
+
+ public override InterceptionResult<object> ScalarExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<object> result)
+ {
+ return InterceptionResult<object>.SuppressWithResult(_retryPolicy.Execute(() => command.ExecuteScalar()!));
+ }
+
+ public override async ValueTask<InterceptionResult<object>> ScalarExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<object> result, CancellationToken cancellationToken = default)
+ {
+ return InterceptionResult<object>.SuppressWithResult((await _asyncRetryPolicy.ExecuteAsync(async () => await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false)!).ConfigureAwait(false))!);
+ }
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ return InterceptionResult<DbDataReader>.SuppressWithResult(_retryPolicy.Execute(command.ExecuteReader));
+ }
+
+ public override async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default)
+ {
+ return InterceptionResult<DbDataReader>.SuppressWithResult(await _asyncRetryPolicy.ExecuteAsync(async () => await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false));
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs
new file mode 100644
index 000000000..2d6bc6902
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs
@@ -0,0 +1,296 @@
+#pragma warning disable MT1013 // Releasing lock without guarantee of execution
+#pragma warning disable MT1012 // Acquiring lock without guarantee of releasing
+
+using System;
+using System.Data;
+using System.Data.Common;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Database.Implementations.Locking;
+
+/// <summary>
+/// A locking behavior that will always block any operation while a write is requested. Mimicks the old SqliteRepository behavior.
+/// </summary>
+public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
+{
+ private readonly ILogger<PessimisticLockBehavior> _logger;
+ private readonly ILoggerFactory _loggerFactory;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PessimisticLockBehavior"/> class.
+ /// </summary>
+ /// <param name="logger">The application logger.</param>
+ /// <param name="loggerFactory">The logger factory.</param>
+ public PessimisticLockBehavior(ILogger<PessimisticLockBehavior> logger, ILoggerFactory loggerFactory)
+ {
+ _logger = logger;
+ _loggerFactory = loggerFactory;
+ }
+
+ private static ReaderWriterLockSlim DatabaseLock { get; } = new(LockRecursionPolicy.SupportsRecursion);
+
+ /// <inheritdoc/>
+ public void OnSaveChanges(JellyfinDbContext context, Action saveChanges)
+ {
+ using (DbLock.EnterWrite(_logger))
+ {
+ saveChanges();
+ }
+ }
+
+ /// <inheritdoc/>
+ public void Initialise(DbContextOptionsBuilder optionsBuilder)
+ {
+ _logger.LogInformation("The database locking mode has been set to: Pessimistic.");
+ optionsBuilder.AddInterceptors(new CommandLockingInterceptor(_loggerFactory.CreateLogger<CommandLockingInterceptor>()));
+ optionsBuilder.AddInterceptors(new TransactionLockingInterceptor(_loggerFactory.CreateLogger<TransactionLockingInterceptor>()));
+ }
+
+ /// <inheritdoc/>
+ public async Task OnSaveChangesAsync(JellyfinDbContext context, Func<Task> saveChanges)
+ {
+ using (DbLock.EnterWrite(_logger))
+ {
+ await saveChanges().ConfigureAwait(false);
+ }
+ }
+
+ private sealed class TransactionLockingInterceptor : DbTransactionInterceptor
+ {
+ private readonly ILogger _logger;
+
+ public TransactionLockingInterceptor(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public override InterceptionResult<DbTransaction> TransactionStarting(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult<DbTransaction> result)
+ {
+ DbLock.BeginWriteLock(_logger);
+
+ return base.TransactionStarting(connection, eventData, result);
+ }
+
+ public override ValueTask<InterceptionResult<DbTransaction>> TransactionStartingAsync(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult<DbTransaction> result, CancellationToken cancellationToken = default)
+ {
+ DbLock.BeginWriteLock(_logger);
+
+ return base.TransactionStartingAsync(connection, eventData, result, cancellationToken);
+ }
+
+ public override void TransactionCommitted(DbTransaction transaction, TransactionEndEventData eventData)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ base.TransactionCommitted(transaction, eventData);
+ }
+
+ public override Task TransactionCommittedAsync(DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ return base.TransactionCommittedAsync(transaction, eventData, cancellationToken);
+ }
+
+ public override void TransactionFailed(DbTransaction transaction, TransactionErrorEventData eventData)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ base.TransactionFailed(transaction, eventData);
+ }
+
+ public override Task TransactionFailedAsync(DbTransaction transaction, TransactionErrorEventData eventData, CancellationToken cancellationToken = default)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ return base.TransactionFailedAsync(transaction, eventData, cancellationToken);
+ }
+
+ public override void TransactionRolledBack(DbTransaction transaction, TransactionEndEventData eventData)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ base.TransactionRolledBack(transaction, eventData);
+ }
+
+ public override Task TransactionRolledBackAsync(DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ return base.TransactionRolledBackAsync(transaction, eventData, cancellationToken);
+ }
+ }
+
+ /// <summary>
+ /// Adds strict read/write locking.
+ /// </summary>
+ private sealed class CommandLockingInterceptor : DbCommandInterceptor
+ {
+ private readonly ILogger _logger;
+
+ public CommandLockingInterceptor(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ using (DbLock.EnterWrite(_logger, command))
+ {
+ return InterceptionResult<int>.SuppressWithResult(command.ExecuteNonQuery());
+ }
+ }
+
+ public override async ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
+ {
+ using (DbLock.EnterWrite(_logger, command))
+ {
+ return InterceptionResult<int>.SuppressWithResult(await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false));
+ }
+ }
+
+ public override InterceptionResult<object> ScalarExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<object> result)
+ {
+ using (DbLock.EnterRead(_logger))
+ {
+ return InterceptionResult<object>.SuppressWithResult(command.ExecuteScalar()!);
+ }
+ }
+
+ public override async ValueTask<InterceptionResult<object>> ScalarExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<object> result, CancellationToken cancellationToken = default)
+ {
+ using (DbLock.EnterRead(_logger))
+ {
+ return InterceptionResult<object>.SuppressWithResult((await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!);
+ }
+ }
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ using (DbLock.EnterRead(_logger))
+ {
+ return InterceptionResult<DbDataReader>.SuppressWithResult(command.ExecuteReader()!);
+ }
+ }
+
+ public override async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default)
+ {
+ using (DbLock.EnterRead(_logger))
+ {
+ return InterceptionResult<DbDataReader>.SuppressWithResult(await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false));
+ }
+ }
+ }
+
+ private sealed class DbLock : IDisposable
+ {
+ private readonly Action? _action;
+ private bool _disposed;
+
+ private static readonly IDisposable _noLock = new DbLock(null) { _disposed = true };
+ private static (string Command, Guid Id, DateTimeOffset QueryDate, bool Printed) _blockQuery;
+
+ public DbLock(Action? action = null)
+ {
+ _action = action;
+ }
+
+#pragma warning disable IDISP015 // Member should not return created and cached instance
+ public static IDisposable EnterWrite(ILogger logger, IDbCommand? command = null, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+#pragma warning restore IDISP015 // Member should not return created and cached instance
+ {
+ logger.LogTrace("Enter Write for {Caller}:{Line}", callerMemberName, callerNo);
+ if (DatabaseLock.IsWriteLockHeld)
+ {
+ logger.LogTrace("Write Held {Caller}:{Line}", callerMemberName, callerNo);
+ return _noLock;
+ }
+
+ BeginWriteLock(logger, command, callerMemberName, callerNo);
+ return new DbLock(() =>
+ {
+ EndWriteLock(logger, callerMemberName, callerNo);
+ });
+ }
+
+#pragma warning disable IDISP015 // Member should not return created and cached instance
+ public static IDisposable EnterRead(ILogger logger, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+#pragma warning restore IDISP015 // Member should not return created and cached instance
+ {
+ logger.LogTrace("Enter Read {Caller}:{Line}", callerMemberName, callerNo);
+ if (DatabaseLock.IsWriteLockHeld)
+ {
+ logger.LogTrace("Write Held {Caller}:{Line}", callerMemberName, callerNo);
+ return _noLock;
+ }
+
+ BeginReadLock(logger, callerMemberName, callerNo);
+ return new DbLock(() =>
+ {
+ ExitReadLock(logger, callerMemberName, callerNo);
+ });
+ }
+
+ public static void BeginWriteLock(ILogger logger, IDbCommand? command = null, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+ {
+ logger.LogTrace("Aquire Write {Caller}:{Line}", callerMemberName, callerNo);
+ if (!DatabaseLock.TryEnterWriteLock(TimeSpan.FromMilliseconds(1000)))
+ {
+ var blockingQuery = _blockQuery;
+ if (!blockingQuery.Printed)
+ {
+ _blockQuery = (blockingQuery.Command, blockingQuery.Id, blockingQuery.QueryDate, true);
+ logger.LogInformation("QueryLock: {Id} --- {Query}", blockingQuery.Id, blockingQuery.Command);
+ }
+
+ logger.LogInformation("Query congestion detected: '{Id}' since '{Date}'", blockingQuery.Id, blockingQuery.QueryDate);
+
+ DatabaseLock.EnterWriteLock();
+
+ logger.LogInformation("Query congestion cleared: '{Id}' for '{Date}'", blockingQuery.Id, DateTimeOffset.Now - blockingQuery.QueryDate);
+ }
+
+ _blockQuery = (command?.CommandText ?? "Transaction", Guid.NewGuid(), DateTimeOffset.Now, false);
+
+ logger.LogTrace("Write Aquired {Caller}:{Line}", callerMemberName, callerNo);
+ }
+
+ public static void BeginReadLock(ILogger logger, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+ {
+ logger.LogTrace("Aquire Write {Caller}:{Line}", callerMemberName, callerNo);
+ DatabaseLock.EnterReadLock();
+ logger.LogTrace("Read Aquired {Caller}:{Line}", callerMemberName, callerNo);
+ }
+
+ public static void EndWriteLock(ILogger logger, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+ {
+ logger.LogTrace("Release Write {Caller}:{Line}", callerMemberName, callerNo);
+ DatabaseLock.ExitWriteLock();
+ }
+
+ public static void ExitReadLock(ILogger logger, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+ {
+ logger.LogTrace("Release Read {Caller}:{Line}", callerMemberName, callerNo);
+ DatabaseLock.ExitReadLock();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ if (_action is not null)
+ {
+ _action();
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ProgressablePartitionReporting.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ProgressablePartitionReporting.cs
new file mode 100644
index 000000000..7654dd3c5
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ProgressablePartitionReporting.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Diagnostics;
+using System.Linq;
+
+namespace Jellyfin.Database.Implementations;
+
+/// <summary>
+/// Wrapper for progress reporting on Partition helpers.
+/// </summary>
+/// <typeparam name="TEntity">The entity to load.</typeparam>
+public class ProgressablePartitionReporting<TEntity>
+{
+ private readonly IOrderedQueryable<TEntity> _source;
+
+ private readonly Stopwatch _partitionTime = new();
+
+ private readonly Stopwatch _itemTime = new();
+
+ internal ProgressablePartitionReporting(IOrderedQueryable<TEntity> source)
+ {
+ _source = source;
+ }
+
+ internal Action<TEntity, int, int>? OnBeginItem { get; set; }
+
+ internal Action<int>? OnBeginPartition { get; set; }
+
+ internal Action<TEntity, int, int, TimeSpan>? OnEndItem { get; set; }
+
+ internal Action<int, TimeSpan>? OnEndPartition { get; set; }
+
+ internal IOrderedQueryable<TEntity> Source => _source;
+
+ internal void BeginItem(TEntity entity, int iteration, int itemIndex)
+ {
+ _itemTime.Restart();
+ OnBeginItem?.Invoke(entity, iteration, itemIndex);
+ }
+
+ internal void BeginPartition(int iteration)
+ {
+ _partitionTime.Restart();
+ OnBeginPartition?.Invoke(iteration);
+ }
+
+ internal void EndItem(TEntity entity, int iteration, int itemIndex)
+ {
+ OnEndItem?.Invoke(entity, iteration, itemIndex, _itemTime.Elapsed);
+ }
+
+ internal void EndPartition(int iteration)
+ {
+ OnEndPartition?.Invoke(iteration, _partitionTime.Elapsed);
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs
new file mode 100644
index 000000000..bb66bddca
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs
@@ -0,0 +1,215 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jellyfin.Database.Implementations;
+
+/// <summary>
+/// Contains helpers to partition EFCore queries.
+/// </summary>
+public static class QueryPartitionHelpers
+{
+ /// <summary>
+ /// Adds a callback to any directly following calls of Partition for every partition thats been invoked.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="query">The source query.</param>
+ /// <param name="beginPartition">The callback invoked for partition before enumerating items.</param>
+ /// <param name="endPartition">The callback invoked for partition after enumerating items.</param>
+ /// <returns>A queryable that can be used to partition.</returns>
+ public static ProgressablePartitionReporting<TEntity> WithPartitionProgress<TEntity>(this IOrderedQueryable<TEntity> query, Action<int>? beginPartition = null, Action<int, TimeSpan>? endPartition = null)
+ {
+ var progressable = new ProgressablePartitionReporting<TEntity>(query);
+ progressable.OnBeginPartition = beginPartition;
+ progressable.OnEndPartition = endPartition;
+ return progressable;
+ }
+
+ /// <summary>
+ /// Adds a callback to any directly following calls of Partition for every item thats been invoked.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="query">The source query.</param>
+ /// <param name="beginItem">The callback invoked for each item before processing.</param>
+ /// <param name="endItem">The callback invoked for each item after processing.</param>
+ /// <returns>A queryable that can be used to partition.</returns>
+ public static ProgressablePartitionReporting<TEntity> WithItemProgress<TEntity>(this IOrderedQueryable<TEntity> query, Action<TEntity, int, int>? beginItem = null, Action<TEntity, int, int, TimeSpan>? endItem = null)
+ {
+ var progressable = new ProgressablePartitionReporting<TEntity>(query);
+ progressable.OnBeginItem = beginItem;
+ progressable.OnEndItem = endItem;
+ return progressable;
+ }
+
+ /// <summary>
+ /// Adds a callback to any directly following calls of Partition for every partition thats been invoked.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="progressable">The source query.</param>
+ /// <param name="beginPartition">The callback invoked for partition before enumerating items.</param>
+ /// <param name="endPartition">The callback invoked for partition after enumerating items.</param>
+ /// <returns>A queryable that can be used to partition.</returns>
+ public static ProgressablePartitionReporting<TEntity> WithPartitionProgress<TEntity>(this ProgressablePartitionReporting<TEntity> progressable, Action<int>? beginPartition = null, Action<int, TimeSpan>? endPartition = null)
+ {
+ progressable.OnBeginPartition = beginPartition;
+ progressable.OnEndPartition = endPartition;
+ return progressable;
+ }
+
+ /// <summary>
+ /// Adds a callback to any directly following calls of Partition for every item thats been invoked.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="progressable">The source query.</param>
+ /// <param name="beginItem">The callback invoked for each item before processing.</param>
+ /// <param name="endItem">The callback invoked for each item after processing.</param>
+ /// <returns>A queryable that can be used to partition.</returns>
+ public static ProgressablePartitionReporting<TEntity> WithItemProgress<TEntity>(this ProgressablePartitionReporting<TEntity> progressable, Action<TEntity, int, int>? beginItem = null, Action<TEntity, int, int, TimeSpan>? endItem = null)
+ {
+ progressable.OnBeginItem = beginItem;
+ progressable.OnEndItem = endItem;
+ return progressable;
+ }
+
+ /// <summary>
+ /// Enumerates the source query by loading the entities in partitions in a lazy manner reading each item from the database as its requested.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="partitionInfo">The source query.</param>
+ /// <param name="partitionSize">The number of elements to load per partition.</param>
+ /// <param name="cancellationToken">The cancelation token.</param>
+ /// <returns>A enumerable representing the whole of the query.</returns>
+ public static async IAsyncEnumerable<TEntity> PartitionAsync<TEntity>(this ProgressablePartitionReporting<TEntity> partitionInfo, int partitionSize, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await foreach (var item in partitionInfo.Source.PartitionAsync(partitionSize, partitionInfo, cancellationToken).ConfigureAwait(false))
+ {
+ yield return item;
+ }
+ }
+
+ /// <summary>
+ /// Enumerates the source query by loading the entities in partitions directly into memory.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="partitionInfo">The source query.</param>
+ /// <param name="partitionSize">The number of elements to load per partition.</param>
+ /// <param name="cancellationToken">The cancelation token.</param>
+ /// <returns>A enumerable representing the whole of the query.</returns>
+ public static async IAsyncEnumerable<TEntity> PartitionEagerAsync<TEntity>(this ProgressablePartitionReporting<TEntity> partitionInfo, int partitionSize, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await foreach (var item in partitionInfo.Source.PartitionEagerAsync(partitionSize, partitionInfo, cancellationToken).ConfigureAwait(false))
+ {
+ yield return item;
+ }
+ }
+
+ /// <summary>
+ /// Enumerates the source query by loading the entities in partitions in a lazy manner reading each item from the database as its requested.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="query">The source query.</param>
+ /// <param name="partitionSize">The number of elements to load per partition.</param>
+ /// <param name="progressablePartition">Reporting helper.</param>
+ /// <param name="cancellationToken">The cancelation token.</param>
+ /// <returns>A enumerable representing the whole of the query.</returns>
+ public static async IAsyncEnumerable<TEntity> PartitionAsync<TEntity>(
+ this IOrderedQueryable<TEntity> query,
+ int partitionSize,
+ ProgressablePartitionReporting<TEntity>? progressablePartition = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var iterator = 0;
+ int itemCounter;
+ do
+ {
+ progressablePartition?.BeginPartition(iterator);
+ itemCounter = 0;
+ await foreach (var item in query
+ .Skip(partitionSize * iterator)
+ .Take(partitionSize)
+ .AsAsyncEnumerable()
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ progressablePartition?.BeginItem(item, iterator, itemCounter);
+ yield return item;
+ progressablePartition?.EndItem(item, iterator, itemCounter);
+ itemCounter++;
+ }
+
+ progressablePartition?.EndPartition(iterator);
+ iterator++;
+ } while (itemCounter == partitionSize && !cancellationToken.IsCancellationRequested);
+ }
+
+ /// <summary>
+ /// Enumerates the source query by loading the entities in partitions directly into memory.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="query">The source query.</param>
+ /// <param name="partitionSize">The number of elements to load per partition.</param>
+ /// <param name="progressablePartition">Reporting helper.</param>
+ /// <param name="cancellationToken">The cancelation token.</param>
+ /// <returns>A enumerable representing the whole of the query.</returns>
+ public static async IAsyncEnumerable<TEntity> PartitionEagerAsync<TEntity>(
+ this IOrderedQueryable<TEntity> query,
+ int partitionSize,
+ ProgressablePartitionReporting<TEntity>? progressablePartition = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var iterator = 0;
+ int itemCounter;
+ var items = ArrayPool<TEntity>.Shared.Rent(partitionSize);
+ try
+ {
+ do
+ {
+ progressablePartition?.BeginPartition(iterator);
+ itemCounter = 0;
+ await foreach (var item in query
+ .Skip(partitionSize * iterator)
+ .Take(partitionSize)
+ .AsAsyncEnumerable()
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ items[itemCounter++] = item;
+ }
+
+ for (int i = 0; i < itemCounter; i++)
+ {
+ progressablePartition?.BeginItem(items[i], iterator, itemCounter);
+ yield return items[i];
+ progressablePartition?.EndItem(items[i], iterator, itemCounter);
+ }
+
+ progressablePartition?.EndPartition(iterator);
+ iterator++;
+ } while (itemCounter == partitionSize && !cancellationToken.IsCancellationRequested);
+ }
+ finally
+ {
+ ArrayPool<TEntity>.Shared.Return(items);
+ }
+ }
+
+ /// <summary>
+ /// Adds an Index to the enumeration of the async enumerable.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="query">The source query.</param>
+ /// <returns>The source list with an index added.</returns>
+ public static async IAsyncEnumerable<(TEntity Item, int Index)> WithIndex<TEntity>(this IAsyncEnumerable<TEntity> query)
+ {
+ var index = 0;
+ await foreach (var item in query.ConfigureAwait(false))
+ {
+ yield return (item, index++);
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs
index 78815c311..4d420bf8c 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs
@@ -1,4 +1,5 @@
using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Locking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Logging.Abstractions;
@@ -19,7 +20,8 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
- new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance));
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
index 519584003..dda1ca075 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
@@ -129,6 +129,21 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
return Task.CompletedTask;
}
+ /// <inheritdoc />
+ public Task DeleteBackup(string key)
+ {
+ var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName, $"{key}_jellyfin.db");
+
+ if (!File.Exists(backupFile))
+ {
+ _logger.LogCritical("Tried to delete a backup that does not exist: {Key}", key);
+ return Task.CompletedTask;
+ }
+
+ File.Delete(backupFile);
+ return Task.CompletedTask;
+ }
+
/// <inheritdoc/>
public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames)
{
diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs
index 80a5741df..126d9f15c 100644
--- a/src/Jellyfin.Networking/Manager/NetworkManager.cs
+++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs
@@ -690,33 +690,42 @@ public class NetworkManager : INetworkManager, IDisposable
}
/// <inheritdoc/>
- public bool HasRemoteAccess(IPAddress remoteIP)
+ public RemoteAccessPolicyResult ShouldAllowServerAccess(IPAddress remoteIP)
{
var config = _configurationManager.GetNetworkConfiguration();
- if (config.EnableRemoteAccess)
+ if (IsInLocalNetwork(remoteIP))
{
- // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely.
- // If left blank, all remote addresses will be allowed.
- if (_remoteAddressFilter.Any() && !IsInLocalNetwork(remoteIP))
- {
- // remoteAddressFilter is a whitelist or blacklist.
- var matches = _remoteAddressFilter.Count(remoteNetwork => NetworkUtils.SubnetContainsAddress(remoteNetwork, remoteIP));
- if ((!config.IsRemoteIPFilterBlacklist && matches > 0)
- || (config.IsRemoteIPFilterBlacklist && matches == 0))
- {
- return true;
- }
-
- return false;
- }
+ return RemoteAccessPolicyResult.Allow;
}
- else if (!IsInLocalNetwork(remoteIP))
+
+ if (!config.EnableRemoteAccess)
{
// Remote not enabled. So everyone should be LAN.
- return false;
+ return RemoteAccessPolicyResult.RejectDueToRemoteAccessDisabled;
}
- return true;
+ if (!_remoteAddressFilter.Any())
+ {
+ // No filter on remote addresses, allow any of them.
+ return RemoteAccessPolicyResult.Allow;
+ }
+
+ // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely.
+ // If left blank, all remote addresses will be allowed.
+
+ // remoteAddressFilter is a whitelist or blacklist.
+ var anyMatches = _remoteAddressFilter.Any(remoteNetwork => NetworkUtils.SubnetContainsAddress(remoteNetwork, remoteIP));
+ if (config.IsRemoteIPFilterBlacklist)
+ {
+ return anyMatches
+ ? RemoteAccessPolicyResult.RejectDueToIPBlocklist
+ : RemoteAccessPolicyResult.Allow;
+ }
+
+ // Allow-list
+ return anyMatches
+ ? RemoteAccessPolicyResult.Allow
+ : RemoteAccessPolicyResult.RejectDueToNotAllowlistedRemoteIP;
}
/// <inheritdoc/>