From 0a2cf69a5500ef3feb2964011e27c5c5bb599de7 Mon Sep 17 00:00:00 2001 From: evan314159 <110177090+evan314159@users.noreply.github.com> Date: Mon, 8 Sep 2025 04:40:27 +0800 Subject: Additional debug logging for SQLite connections (#14753) --- .../SqliteDatabaseProvider.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index e52ab69d71..ccf84e6012 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -47,13 +47,25 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider sqliteConnectionBuilder.Cache = Enum.Parse(databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("cache", StringComparison.OrdinalIgnoreCase))?.Value ?? nameof(SqliteCacheMode.Default)); sqliteConnectionBuilder.Pooling = (databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("pooling", StringComparison.OrdinalIgnoreCase))?.Value ?? bool.FalseString).Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase); + var connectionString = sqliteConnectionBuilder.ToString(); + + // Log SQLite connection parameters + _logger.LogInformation("SQLite connection string: {ConnectionString}", connectionString); + options .UseSqlite( - sqliteConnectionBuilder.ToString(), + connectionString, sqLiteOptions => sqLiteOptions.MigrationsAssembly(GetType().Assembly)) // TODO: Remove when https://github.com/dotnet/efcore/pull/35873 is merged & released .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.NonTransactionalMigrationOperationWarning)); + + var enableSensitiveDataLoggingOption = databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("EnableSensitiveDataLogging", StringComparison.OrdinalIgnoreCase))?.Value; + if (!string.IsNullOrEmpty(enableSensitiveDataLoggingOption) && bool.TryParse(enableSensitiveDataLoggingOption, out bool enableSensitiveDataLogging) && enableSensitiveDataLogging) + { + options.EnableSensitiveDataLogging(enableSensitiveDataLogging); + _logger.LogInformation("EnableSensitiveDataLogging is enabled on SQLite connection"); + } } /// -- cgit v1.2.3 From a99e67544a82da388ce041f836355f86dbc03609 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Fri, 12 Sep 2025 21:57:33 +0200 Subject: Reenable pooling (#14778) --- .../ScheduledTasks/Tasks/OptimizeDatabaseTask.cs | 2 +- .../SqliteDatabaseProvider.cs | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index bf8ffaf479..92d7a3907a 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -61,7 +61,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, - IntervalTicks = TimeSpan.FromHours(24).Ticks + IntervalTicks = TimeSpan.FromHours(6).Ticks }; } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index ccf84e6012..d51e8fd645 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -45,7 +45,7 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider var sqliteConnectionBuilder = new SqliteConnectionStringBuilder(); sqliteConnectionBuilder.DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); sqliteConnectionBuilder.Cache = Enum.Parse(databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("cache", StringComparison.OrdinalIgnoreCase))?.Value ?? nameof(SqliteCacheMode.Default)); - sqliteConnectionBuilder.Pooling = (databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("pooling", StringComparison.OrdinalIgnoreCase))?.Value ?? bool.FalseString).Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase); + sqliteConnectionBuilder.Pooling = (databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("pooling", StringComparison.OrdinalIgnoreCase))?.Value ?? bool.TrueString).Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase); var connectionString = sqliteConnectionBuilder.ToString(); @@ -74,16 +74,11 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - if (context.Database.IsSqlite()) - { - await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); - _logger.LogInformation("jellyfin.db optimized successfully!"); - } - else - { - _logger.LogInformation("This database doesn't support optimization"); - } + await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); + await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); + await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); + await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); + _logger.LogInformation("jellyfin.db optimized successfully!"); } } -- cgit v1.2.3 From f0e60a7ff332601b4cc955969c09bdf6f1dd0118 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Fri, 12 Sep 2025 21:57:40 +0200 Subject: Improve optimistic locking behavior (#14779) --- .../Locking/OptimisticLockBehavior.cs | 24 ++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs index 9395b2e2dd..b90a2e056f 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs @@ -1,6 +1,7 @@ using System; using System.Data.Common; using System.Linq; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -28,15 +29,34 @@ public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior TimeSpan[] sleepDurations = [ TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(250), + TimeSpan.FromMilliseconds(250), + TimeSpan.FromMilliseconds(150), + TimeSpan.FromMilliseconds(150), TimeSpan.FromMilliseconds(150), TimeSpan.FromMilliseconds(500), + TimeSpan.FromMilliseconds(150), TimeSpan.FromMilliseconds(500), + TimeSpan.FromMilliseconds(150), TimeSpan.FromSeconds(3) ]; + + Func backoffProvider = (index, context) => + { + var backoff = sleepDurations[index]; + return backoff + TimeSpan.FromMilliseconds(RandomNumberGenerator.GetInt32(0, (int)(backoff.TotalMilliseconds * .5))); + }; + _logger = logger; - _writePolicy = Policy.HandleInner(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)).WaitAndRetry(sleepDurations, RetryHandle); - _writeAsyncPolicy = Policy.HandleInner(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)).WaitAndRetryAsync(sleepDurations, RetryHandle); + _writePolicy = Policy + .HandleInner(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)) + .WaitAndRetry(sleepDurations.Length, backoffProvider, RetryHandle); + _writeAsyncPolicy = Policy + .HandleInner(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)) + .WaitAndRetryAsync(sleepDurations.Length, backoffProvider, RetryHandle); void RetryHandle(Exception exception, TimeSpan timespan, int retryNo, Context context) { -- cgit v1.2.3 From 6796b3435d893fde8c1ae7551f52b1fbb1bc489c Mon Sep 17 00:00:00 2001 From: Bond-009 Date: Fri, 12 Sep 2025 21:58:28 +0200 Subject: Avoid constant arrays as arguments (#14784) --- .editorconfig | 3 +++ MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs | 8 ++++---- MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs | 15 ++++++++------- .../Probing/ProbeResultNormalizer.cs | 14 ++++++++------ src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs | 7 +++---- 5 files changed, 26 insertions(+), 21 deletions(-) (limited to 'src') diff --git a/.editorconfig b/.editorconfig index ab5d3d9dd1..313b02563d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -294,6 +294,9 @@ dotnet_diagnostic.CA1854.severity = error # error on CA1860: Avoid using 'Enumerable.Any()' extension method dotnet_diagnostic.CA1860.severity = error +# error on CA1861: Avoid constant arrays as arguments +dotnet_diagnostic.CA1861.severity = error + # error on CA1862: Use the 'StringComparison' method overloads to perform case-insensitive string comparisons dotnet_diagnostic.CA1862.severity = error diff --git a/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs b/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs index 2742f21e36..b53210b0b1 100644 --- a/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs +++ b/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs @@ -167,12 +167,12 @@ public static class XmlReaderExtensions // Only split by comma if there is no pipe in the string // We have to be careful to not split names like Matthew, Jr. - var separator = !value.Contains('|', StringComparison.Ordinal) + ReadOnlySpan separator = !value.Contains('|', StringComparison.Ordinal) && !value.Contains(';', StringComparison.Ordinal) - ? new[] { ',' } - : new[] { '|', ';' }; + ? stackalloc[] { ',' } + : stackalloc[] { '|', ';' }; - foreach (var part in value.Trim().Trim(separator).Split(separator)) + foreach (var part in value.AsSpan().Trim().Trim(separator).ToString().Split(separator)) { if (!string.IsNullOrWhiteSpace(part)) { diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 8d6211051b..ef912f42c3 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Globalization; using System.Linq; using Jellyfin.Data.Enums; @@ -22,6 +21,8 @@ namespace MediaBrowser.Controller.MediaEncoding // For now, a common base class until the API and MediaEncoding classes are unified public class EncodingJobInfo { + private static readonly char[] _separators = ['|', ',']; + public int? OutputAudioBitrate; public int? OutputAudioChannels; @@ -586,7 +587,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (!string.IsNullOrEmpty(BaseRequest.Profile)) { - return BaseRequest.Profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return BaseRequest.Profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } if (!string.IsNullOrEmpty(codec)) @@ -595,7 +596,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(profile)) { - return profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } } @@ -606,7 +607,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (!string.IsNullOrEmpty(BaseRequest.VideoRangeType)) { - return BaseRequest.VideoRangeType.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return BaseRequest.VideoRangeType.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } if (!string.IsNullOrEmpty(codec)) @@ -615,7 +616,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(rangetype)) { - return rangetype.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return rangetype.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } } @@ -626,7 +627,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (!string.IsNullOrEmpty(BaseRequest.CodecTag)) { - return BaseRequest.CodecTag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return BaseRequest.CodecTag.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } if (!string.IsNullOrEmpty(codec)) @@ -635,7 +636,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(codectag)) { - return codectag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + return codectag.Split(_separators, StringSplitOptions.RemoveEmptyEntries); } } diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 18646ec5dc..00a9ae797d 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -30,9 +30,11 @@ namespace MediaBrowser.MediaEncoding.Probing private const string ArtistReplaceValue = " | "; - private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' }; - private readonly string[] _webmVideoCodecs = { "av1", "vp8", "vp9" }; - private readonly string[] _webmAudioCodecs = { "opus", "vorbis" }; + private static readonly char[] _basicDelimiters = ['/', ';']; + private static readonly char[] _nameDelimiters = [.. _basicDelimiters, '|', '\\']; + private static readonly char[] _genreDelimiters = [.. _basicDelimiters, ',']; + private static readonly string[] _webmVideoCodecs = ["av1", "vp8", "vp9"]; + private static readonly string[] _webmAudioCodecs = ["opus", "vorbis"]; private readonly ILogger _logger; private readonly ILocalizationManager _localization; @@ -174,7 +176,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (tags.TryGetValue("artists", out var artists) && !string.IsNullOrWhiteSpace(artists)) { - info.Artists = SplitDistinctArtists(artists, new[] { '/', ';' }, false).ToArray(); + info.Artists = SplitDistinctArtists(artists, _basicDelimiters, false).ToArray(); } else { @@ -1552,7 +1554,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (tags.TryGetValue("WM/Genre", out var genres) && !string.IsNullOrWhiteSpace(genres)) { - var genreList = genres.Split(new[] { ';', '/', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var genreList = genres.Split(_genreDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); // If this is empty then don't overwrite genres that might have been fetched earlier if (genreList.Length > 0) @@ -1569,7 +1571,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (tags.TryGetValue("WM/MediaCredits", out var people) && !string.IsNullOrEmpty(people)) { video.People = Array.ConvertAll( - people.Split(new[] { ';', '/' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + people.Split(_basicDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), i => new BaseItemPerson { Name = i, Type = PersonKind.Actor }); } diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs index e3afe15131..2270758454 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs @@ -200,8 +200,7 @@ namespace Jellyfin.LiveTv.TunerHosts var numberIndex = nameInExtInf.IndexOf(' '); if (numberIndex > 0) { - var numberPart = nameInExtInf.Slice(0, numberIndex).Trim(new[] { ' ', '.' }); - + var numberPart = nameInExtInf[..numberIndex].Trim(stackalloc[] { ' ', '.' }); if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _)) { numberString = numberPart.ToString(); @@ -273,12 +272,12 @@ namespace Jellyfin.LiveTv.TunerHosts var numberIndex = nameInExtInf.IndexOf(' ', StringComparison.Ordinal); if (numberIndex > 0) { - var numberPart = nameInExtInf.AsSpan(0, numberIndex).Trim(new[] { ' ', '.' }); + var numberPart = nameInExtInf.AsSpan(0, numberIndex).Trim(stackalloc[] { ' ', '.' }); if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _)) { // channel.Number = number.ToString(); - nameInExtInf = nameInExtInf.AsSpan(numberIndex + 1).Trim(new[] { ' ', '-' }).ToString(); + nameInExtInf = nameInExtInf.AsSpan(numberIndex + 1).Trim(stackalloc[] { ' ', '-' }).ToString(); } } } -- cgit v1.2.3 From 24410d8a2e05e7abd552fae2732c337afe0d63bd Mon Sep 17 00:00:00 2001 From: JPVenson Date: Sun, 14 Sep 2025 19:24:35 +0200 Subject: Reenable common PRAGMA setters (#14791) --- .../PragmaConnectionInterceptor.cs | 108 +++++++++++++++++++++ .../SqliteDatabaseProvider.cs | 38 ++++++-- 2 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/PragmaConnectionInterceptor.cs (limited to 'src') diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/PragmaConnectionInterceptor.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/PragmaConnectionInterceptor.cs new file mode 100644 index 0000000000..47e44d97b9 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/PragmaConnectionInterceptor.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Database.Providers.Sqlite; + +/// +/// Injects a series of PRAGMA on each connection starts. +/// +public class PragmaConnectionInterceptor : DbConnectionInterceptor +{ + private readonly ILogger _logger; + private readonly int? _cacheSize; + private readonly string _lockingMode; + private readonly int? _journalSizeLimit; + private readonly int _tempStoreMode; + private readonly int _syncMode; + private readonly IDictionary _customPragma; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// Cache size. + /// Locking mode. + /// Journal Size. + /// The https://sqlite.org/pragma.html#pragma_temp_store pragma. + /// The https://sqlite.org/pragma.html#pragma_synchronous pragma. + /// A list of custom provided Pragma in the list of CustomOptions starting with "#PRAGMA:". + public PragmaConnectionInterceptor(ILogger logger, int? cacheSize, string lockingMode, int? journalSizeLimit, int tempStoreMode, int syncMode, IDictionary customPragma) + { + _logger = logger; + _cacheSize = cacheSize; + _lockingMode = lockingMode; + _journalSizeLimit = journalSizeLimit; + _tempStoreMode = tempStoreMode; + _syncMode = syncMode; + _customPragma = customPragma; + + InitialCommand = BuildCommandText(); + _logger.LogInformation("SQLITE connection pragma command set to: \r\n {PragmaCommand}", InitialCommand); + } + + private string? InitialCommand { get; set; } + + /// + public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) + { + base.ConnectionOpened(connection, eventData); + + using (var command = connection.CreateCommand()) + { +#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities + command.CommandText = InitialCommand; +#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities + command.ExecuteNonQuery(); + } + } + + /// + public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default) + { + await base.ConnectionOpenedAsync(connection, eventData, cancellationToken).ConfigureAwait(false); + + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { +#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities + command.CommandText = InitialCommand; +#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + } + + private string BuildCommandText() + { + var sb = new StringBuilder(); + if (_cacheSize.HasValue) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA cache_size={_cacheSize.Value};"); + } + + if (!string.IsNullOrWhiteSpace(_lockingMode)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA locking_mode={_lockingMode};"); + } + + if (_journalSizeLimit.HasValue) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA journal_size_limit={_journalSizeLimit};"); + } + + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA synchronous={_syncMode};"); + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA temp_store={_tempStoreMode};"); + + foreach (var item in _customPragma) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA {item.Key}={item.Value};"); + } + + return sb.ToString(); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index d51e8fd645..2b000b257b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -42,10 +42,28 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider /// public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration) { + static T? GetOption(ICollection? options, string key, Func converter, Func? defaultValue = null) + { + if (options is null) + { + return defaultValue is not null ? defaultValue() : default; + } + + var value = options.FirstOrDefault(e => e.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); + if (value is null) + { + return defaultValue is not null ? defaultValue() : default; + } + + return converter(value.Value); + } + + var customOptions = databaseConfiguration.CustomProviderOptions?.Options; + var sqliteConnectionBuilder = new SqliteConnectionStringBuilder(); sqliteConnectionBuilder.DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); - sqliteConnectionBuilder.Cache = Enum.Parse(databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("cache", StringComparison.OrdinalIgnoreCase))?.Value ?? nameof(SqliteCacheMode.Default)); - sqliteConnectionBuilder.Pooling = (databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("pooling", StringComparison.OrdinalIgnoreCase))?.Value ?? bool.TrueString).Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase); + sqliteConnectionBuilder.Cache = GetOption(customOptions, "cache", Enum.Parse, () => SqliteCacheMode.Default); + sqliteConnectionBuilder.Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true); var connectionString = sqliteConnectionBuilder.ToString(); @@ -58,10 +76,18 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider sqLiteOptions => sqLiteOptions.MigrationsAssembly(GetType().Assembly)) // TODO: Remove when https://github.com/dotnet/efcore/pull/35873 is merged & released .ConfigureWarnings(warnings => - warnings.Ignore(RelationalEventId.NonTransactionalMigrationOperationWarning)); - - var enableSensitiveDataLoggingOption = databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("EnableSensitiveDataLogging", StringComparison.OrdinalIgnoreCase))?.Value; - if (!string.IsNullOrEmpty(enableSensitiveDataLoggingOption) && bool.TryParse(enableSensitiveDataLoggingOption, out bool enableSensitiveDataLogging) && enableSensitiveDataLogging) + warnings.Ignore(RelationalEventId.NonTransactionalMigrationOperationWarning)) + .AddInterceptors(new PragmaConnectionInterceptor( + _logger, + GetOption(customOptions, "cacheSize", e => int.Parse(e, CultureInfo.InvariantCulture)), + GetOption(customOptions, "lockingmode", e => e, () => "NORMAL")!, + GetOption(customOptions, "journalsizelimit", int.Parse, () => 134_217_728), + GetOption(customOptions, "tempstoremode", int.Parse, () => 2), + GetOption(customOptions, "syncmode", int.Parse, () => 1), + customOptions?.Where(e => e.Key.StartsWith("#PRAGMA:", StringComparison.OrdinalIgnoreCase)).ToDictionary(e => e.Key["#PRAGMA:".Length..], e => e.Value) ?? [])); + + var enableSensitiveDataLogging = GetOption(customOptions, "EnableSensitiveDataLogging", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => false); + if (enableSensitiveDataLogging) { options.EnableSensitiveDataLogging(enableSensitiveDataLogging); _logger.LogInformation("EnableSensitiveDataLogging is enabled on SQLite connection"); -- cgit v1.2.3 From 2618a5fba23432c89882bf343f481f4248ae7ab3 Mon Sep 17 00:00:00 2001 From: evan314159 <110177090+evan314159@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:14:52 +0800 Subject: Fix sync disposal of async-created IAsyncDisposable objects (#14755) --- Directory.Build.props | 5 + Directory.Packages.props | 7 +- .../Item/KeyframeRepository.cs | 13 +- .../MediaSegments/MediaSegmentManager.cs | 200 +++++++++++---------- Jellyfin.Server/Migrations/Routines/FixDates.cs | 19 +- Jellyfin.sln | 7 + .../Subtitles/SubtitleEncoder.cs | 6 +- .../AnalyzerReleases.Shipped.md | 9 + .../AsyncDisposalPatternAnalyzer.cs | 82 +++++++++ .../Jellyfin.CodeAnalysis.csproj | 17 ++ 10 files changed, 255 insertions(+), 110 deletions(-) create mode 100644 src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md create mode 100644 src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs create mode 100644 src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj (limited to 'src') diff --git a/Directory.Build.props b/Directory.Build.props index 31ae8bfbe4..9007141710 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -19,4 +19,9 @@ + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index c07c06e208..f0c13b7c75 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,6 +29,9 @@ + + + @@ -70,7 +73,7 @@ - + @@ -93,4 +96,4 @@ - + \ No newline at end of file diff --git a/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs index 93c6f472e2..438458c6be 100644 --- a/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs +++ b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs @@ -55,11 +55,14 @@ public class KeyframeRepository : IKeyframeRepository public async Task SaveKeyframeDataAsync(Guid itemId, MediaEncoding.Keyframes.KeyframeData data, CancellationToken cancellationToken) { using var context = _dbProvider.CreateDbContext(); - using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); - await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); - await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false); - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await using (transaction.ConfigureAwait(false)) + { + await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } } /// diff --git a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs index 97c9d79f53..d00c87463c 100644 --- a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs +++ b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs @@ -68,87 +68,89 @@ public class MediaSegmentManager : IMediaSegmentManager return; } - using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - - _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count); - - if (forceOverwrite) - { - // delete all existing media segments if forceOverwrite is set. - await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); - } - - foreach (var provider in providers) + var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (db.ConfigureAwait(false)) { - if (!await provider.Supports(baseItem).ConfigureAwait(false)) - { - _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path); - continue; - } + _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count); - IQueryable existingSegments; if (forceOverwrite) { - existingSegments = Array.Empty().AsQueryable(); - } - else - { - existingSegments = db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id) && e.SegmentProviderId == GetProviderId(provider.Name)); + // delete all existing media segments if forceOverwrite is set. + await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); } - var requestItem = new MediaSegmentGenerationRequest() + foreach (var provider in providers) { - ItemId = baseItem.Id, - ExistingSegments = existingSegments.Select(e => Map(e)).ToArray() - }; + if (!await provider.Supports(baseItem).ConfigureAwait(false)) + { + _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path); + continue; + } - try - { - var segments = await provider.GetMediaSegments(requestItem, cancellationToken) - .ConfigureAwait(false); + IQueryable existingSegments; + if (forceOverwrite) + { + existingSegments = Array.Empty().AsQueryable(); + } + else + { + existingSegments = db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id) && e.SegmentProviderId == GetProviderId(provider.Name)); + } + + var requestItem = new MediaSegmentGenerationRequest() + { + ItemId = baseItem.Id, + ExistingSegments = existingSegments.Select(e => Map(e)).ToArray() + }; - if (!forceOverwrite) + try { - var existingSegmentsList = existingSegments.ToArray(); // Cannot use requestItem's list, as the provider might tamper with its items. - if (segments.Count == requestItem.ExistingSegments.Count && segments.All(e => existingSegmentsList.Any(f => + var segments = await provider.GetMediaSegments(requestItem, cancellationToken) + .ConfigureAwait(false); + + if (!forceOverwrite) + { + var existingSegmentsList = existingSegments.ToArray(); // Cannot use requestItem's list, as the provider might tamper with its items. + if (segments.Count == requestItem.ExistingSegments.Count && segments.All(e => existingSegmentsList.Any(f => + { + return + e.StartTicks == f.StartTicks && + e.EndTicks == f.EndTicks && + e.Type == f.Type; + }))) + { + _logger.LogDebug("Media Segment provider {ProviderName} did not modify any segments for {MediaPath}", provider.Name, baseItem.Path); + continue; + } + + // delete existing media segments that were re-generated. + await existingSegments.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } + + if (segments.Count == 0 && !requestItem.ExistingSegments.Any()) { - return - e.StartTicks == f.StartTicks && - e.EndTicks == f.EndTicks && - e.Type == f.Type; - }))) + _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path); + continue; + } + else if (segments.Count == 0 && requestItem.ExistingSegments.Any()) { - _logger.LogDebug("Media Segment provider {ProviderName} did not modify any segments for {MediaPath}", provider.Name, baseItem.Path); + _logger.LogDebug("Media Segment provider {ProviderName} deleted all segments for {MediaPath}", provider.Name, baseItem.Path); continue; } - // delete existing media segments that were re-generated. - await existingSegments.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); - } - - if (segments.Count == 0 && !requestItem.ExistingSegments.Any()) - { - _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path); - continue; - } - else if (segments.Count == 0 && requestItem.ExistingSegments.Any()) - { - _logger.LogDebug("Media Segment provider {ProviderName} deleted all segments for {MediaPath}", provider.Name, baseItem.Path); - continue; + _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path); + var providerId = GetProviderId(provider.Name); + foreach (var segment in segments) + { + segment.ItemId = baseItem.Id; + await CreateSegmentAsync(segment, providerId).ConfigureAwait(false); + } } - - _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path); - var providerId = GetProviderId(provider.Name); - foreach (var segment in segments) + catch (Exception ex) { - segment.ItemId = baseItem.Id; - await CreateSegmentAsync(segment, providerId).ConfigureAwait(false); + _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path); } } - catch (Exception ex) - { - _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path); - } } } @@ -157,24 +159,34 @@ public class MediaSegmentManager : IMediaSegmentManager { ArgumentOutOfRangeException.ThrowIfLessThan(mediaSegment.EndTicks, mediaSegment.StartTicks); - using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - db.MediaSegments.Add(Map(mediaSegment, segmentProviderId)); - await db.SaveChangesAsync().ConfigureAwait(false); + var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (db.ConfigureAwait(false)) + { + db.MediaSegments.Add(Map(mediaSegment, segmentProviderId)); + await db.SaveChangesAsync().ConfigureAwait(false); + } + return mediaSegment; } /// public async Task DeleteSegmentAsync(Guid segmentId) { - using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await db.MediaSegments.Where(e => e.Id.Equals(segmentId)).ExecuteDeleteAsync().ConfigureAwait(false); + var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (db.ConfigureAwait(false)) + { + await db.MediaSegments.Where(e => e.Id.Equals(segmentId)).ExecuteDeleteAsync().ConfigureAwait(false); + } } /// public async Task DeleteSegmentsAsync(Guid itemId, CancellationToken cancellationToken) { - using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - await db.MediaSegments.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (db.ConfigureAwait(false)) + { + await db.MediaSegments.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } } /// @@ -186,36 +198,38 @@ public class MediaSegmentManager : IMediaSegmentManager return []; } - using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - - var query = db.MediaSegments - .Where(e => e.ItemId.Equals(item.Id)); - - if (typeFilter is not null) + var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (db.ConfigureAwait(false)) { - query = query.Where(e => typeFilter.Contains(e.Type)); - } + var query = db.MediaSegments + .Where(e => e.ItemId.Equals(item.Id)); - if (filterByProvider) - { - var providerIds = _segmentProviders - .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) - .Select(f => GetProviderId(f.Name)) - .ToArray(); - if (providerIds.Length == 0) + if (typeFilter is not null) { - return []; + query = query.Where(e => typeFilter.Contains(e.Type)); } - query = query.Where(e => providerIds.Contains(e.SegmentProviderId)); - } + if (filterByProvider) + { + var providerIds = _segmentProviders + .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) + .Select(f => GetProviderId(f.Name)) + .ToArray(); + if (providerIds.Length == 0) + { + return []; + } - return query - .OrderBy(e => e.StartTicks) - .AsNoTracking() - .AsEnumerable() - .Select(Map) - .ToArray(); + query = query.Where(e => providerIds.Contains(e.SegmentProviderId)); + } + + return query + .OrderBy(e => e.StartTicks) + .AsNoTracking() + .AsEnumerable() + .Select(Map) + .ToArray(); + } } private static MediaSegmentDto Map(MediaSegment segment) diff --git a/Jellyfin.Server/Migrations/Routines/FixDates.cs b/Jellyfin.Server/Migrations/Routines/FixDates.cs index f112502b9f..a5b11b11d0 100644 --- a/Jellyfin.Server/Migrations/Routines/FixDates.cs +++ b/Jellyfin.Server/Migrations/Routines/FixDates.cs @@ -41,14 +41,17 @@ public class FixDates : IAsyncMigrationRoutine { if (!TimeZoneInfo.Local.Equals(TimeZoneInfo.Utc)) { - using var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - var sw = Stopwatch.StartNew(); - - await FixBaseItemsAsync(context, sw, cancellationToken).ConfigureAwait(false); - sw.Reset(); - await FixChaptersAsync(context, sw, cancellationToken).ConfigureAwait(false); - sw.Reset(); - await FixBaseItemImageInfos(context, sw, cancellationToken).ConfigureAwait(false); + var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var sw = Stopwatch.StartNew(); + + await FixBaseItemsAsync(context, sw, cancellationToken).ConfigureAwait(false); + sw.Reset(); + await FixChaptersAsync(context, sw, cancellationToken).ConfigureAwait(false); + sw.Reset(); + await FixBaseItemImageInfos(context, sw, cancellationToken).ConfigureAwait(false); + } } } diff --git a/Jellyfin.sln b/Jellyfin.sln index 21ef13e723..fb1f2a2c20 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -96,6 +96,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -258,6 +260,10 @@ Global {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.ActiveCfg = Release|Any CPU {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.Build.0 = Release|Any CPU + {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -289,6 +295,7 @@ Global {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} {8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} + {11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE} diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 359927d4db..6408f81acc 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -169,7 +169,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles { if (fileInfo.IsExternal) { - using (var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false)) + var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) { var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false); var detected = result.Detected; @@ -937,7 +938,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles .ConfigureAwait(false); } - using (var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false)) + var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) { var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false); var charset = result.Detected?.EncodingName ?? string.Empty; diff --git a/src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md b/src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000000..d23e3f9ed3 --- /dev/null +++ b/src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md @@ -0,0 +1,9 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +## Release 1.0 + +### New Rules +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +JF0001 | Usage | Warning | Async-created IAsyncDisposable objects should use 'await using' diff --git a/src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs b/src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs new file mode 100644 index 0000000000..90c8dfeca7 --- /dev/null +++ b/src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Jellyfin.CodeAnalysis; + +/// +/// Analyzer to detect sync disposal of async-created IAsyncDisposable objects. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class AsyncDisposalPatternAnalyzer : DiagnosticAnalyzer +{ + /// + /// Diagnostic descriptor for sync disposal of async-created IAsyncDisposable objects. + /// + public static readonly DiagnosticDescriptor AsyncDisposableSyncDisposal = new( + id: "JF0001", + title: "Async-created IAsyncDisposable objects should use 'await using'", + messageFormat: "Using 'using' with async-created IAsyncDisposable object '{0}'. Use 'await using' instead to prevent resource leaks.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Objects that implement IAsyncDisposable and are created using 'await' should be disposed using 'await using' to prevent resource leaks."); + + /// + public override ImmutableArray SupportedDiagnostics => [AsyncDisposableSyncDisposal]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeUsingStatement, SyntaxKind.UsingStatement); + } + + private static void AnalyzeUsingStatement(SyntaxNodeAnalysisContext context) + { + var usingStatement = (UsingStatementSyntax)context.Node; + + // Skip 'await using' statements + if (usingStatement.AwaitKeyword.IsKind(SyntaxKind.AwaitKeyword)) + { + return; + } + + // Check if there's a variable declaration + if (usingStatement.Declaration?.Variables is null) + { + return; + } + + foreach (var variable in usingStatement.Declaration.Variables) + { + if (variable.Initializer?.Value is AwaitExpressionSyntax awaitExpression) + { + var typeInfo = context.SemanticModel.GetTypeInfo(awaitExpression); + var type = typeInfo.Type; + + if (type is not null && ImplementsIAsyncDisposable(type)) + { + var diagnostic = Diagnostic.Create( + AsyncDisposableSyncDisposal, + usingStatement.GetLocation(), + type.Name); + + context.ReportDiagnostic(diagnostic); + } + } + } + } + + private static bool ImplementsIAsyncDisposable(ITypeSymbol type) + { + return type.AllInterfaces.Any(i => + string.Equals(i.Name, "IAsyncDisposable", StringComparison.Ordinal) + && string.Equals(i.ContainingNamespace?.ToDisplayString(), "System", StringComparison.Ordinal)); + } +} diff --git a/src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj b/src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj new file mode 100644 index 0000000000..64d20e9044 --- /dev/null +++ b/src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + latest + false + false + true + true + + + + + + + + -- cgit v1.2.3 From a0b3e2b071509f440db10768f6f8984c7ea382d6 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Tue, 16 Sep 2025 21:08:04 +0200 Subject: Optimize internal querying of UserData, other fixes (#14795) --- .../Library/LibraryManager.cs | 10 +- .../Library/MusicManager.cs | 13 +- .../Library/UserDataManager.cs | 8 +- Jellyfin.Api/Controllers/YearsController.cs | 5 +- .../Item/BaseItemRepository.cs | 104 +- .../Item/PeopleRepository.cs | 6 +- MediaBrowser.Controller/Entities/BaseItem.cs | 7 + MediaBrowser.Controller/Entities/Folder.cs | 122 +- MediaBrowser.Controller/Entities/Movies/BoxSet.cs | 4 +- MediaBrowser.Controller/Entities/TV/Season.cs | 2 +- MediaBrowser.Controller/Entities/TV/Series.cs | 1 + MediaBrowser.Controller/Entities/UserView.cs | 10 +- .../Persistence/IItemRepository.cs | 10 + MediaBrowser.Controller/Playlists/Playlist.cs | 6 +- .../Entities/BaseItemEntity.cs | 4 + .../ModelConfiguration/BaseItemConfiguration.cs | 1 + ...entChildRelationBaseItemWithCascade.Designer.cs | 1721 ++++++++++++++++++++ ...ProperParentChildRelationBaseItemWithCascade.cs | 30 + .../Migrations/JellyfinDbModelSnapshot.cs | 14 +- .../Controllers/LibraryStructureControllerTests.cs | 2 + 20 files changed, 1988 insertions(+), 92 deletions(-) create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.Designer.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs (limited to 'src') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 58a971f62a..0074df80a0 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1090,6 +1090,7 @@ namespace Emby.Server.Implementations.Library public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false) { + RootFolder.Children = null; await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); // Start by just validating the children of the root, but go no further @@ -1100,9 +1101,12 @@ namespace Emby.Server.Implementations.Library allowRemoveRoot: removeRoot, cancellationToken: cancellationToken).ConfigureAwait(false); - await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false); + var rootFolder = GetUserRootFolder(); + rootFolder.Children = null; - await GetUserRootFolder().ValidateChildren( + await rootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); + + await rootFolder.ValidateChildren( new Progress(), new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: false, @@ -1110,7 +1114,7 @@ namespace Emby.Server.Implementations.Library cancellationToken: cancellationToken).ConfigureAwait(false); // Quickly scan CollectionFolders for changes - foreach (var child in GetUserRootFolder().Children.OfType()) + foreach (var child in rootFolder.Children!.OfType()) { // If the user has somehow deleted the collection directory, remove the metadata from the database. if (child is CollectionFolder collectionFolder && !Directory.Exists(collectionFolder.Path)) diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index 28cf695007..e0c8ae371b 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -45,11 +45,14 @@ namespace Emby.Server.Implementations.Library public IReadOnlyList GetInstantMixFromFolder(Folder item, User? user, DtoOptions dtoOptions) { var genres = item - .GetRecursiveChildren(user, new InternalItemsQuery(user) - { - IncludeItemTypes = [BaseItemKind.Audio], - DtoOptions = dtoOptions - }) + .GetRecursiveChildren( + user, + new InternalItemsQuery(user) + { + IncludeItemTypes = [BaseItemKind.Audio], + DtoOptions = dtoOptions + }, + out _) .Cast