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,86 +68,88 @@ public class MediaSegmentManager : IMediaSegmentManager return; } - using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - - _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count); - - if (forceOverwrite) + var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (db.ConfigureAwait(false)) { - // delete all existing media segments if forceOverwrite is set. - await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); - } + _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count); - foreach (var provider in providers) - { - if (!await provider.Supports(baseItem).ConfigureAwait(false)) - { - _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path); - continue; - } - - IQueryable 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() - }; - - try - { - var segments = await provider.GetMediaSegments(requestItem, cancellationToken) - .ConfigureAwait(false); - - if (!forceOverwrite) + if (!await provider.Supports(baseItem).ConfigureAwait(false)) { - var existingSegmentsList = existingSegments.ToArray(); // Cannot use requestItem's list, as the provider might tamper with its items. - if (segments.Count == requestItem.ExistingSegments.Count && segments.All(e => existingSegmentsList.Any(f => + _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path); + continue; + } + + IQueryable 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() + }; + + try + { + var segments = await provider.GetMediaSegments(requestItem, cancellationToken) + .ConfigureAwait(false); + + if (!forceOverwrite) { - return - e.StartTicks == f.StartTicks && - e.EndTicks == f.EndTicks && - e.Type == f.Type; - }))) + var existingSegmentsList = existingSegments.ToArray(); // Cannot use requestItem's list, as the provider might tamper with its items. + if (segments.Count == requestItem.ExistingSegments.Count && segments.All(e => existingSegmentsList.Any(f => + { + return + e.StartTicks == f.StartTicks && + e.EndTicks == f.EndTicks && + e.Type == f.Type; + }))) + { + _logger.LogDebug("Media Segment provider {ProviderName} did not modify any segments for {MediaPath}", provider.Name, baseItem.Path); + continue; + } + + // delete existing media segments that were re-generated. + await existingSegments.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } + + if (segments.Count == 0 && !requestItem.ExistingSegments.Any()) { - _logger.LogDebug("Media Segment provider {ProviderName} did not modify any segments for {MediaPath}", provider.Name, baseItem.Path); + _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path); + continue; + } + else if (segments.Count == 0 && requestItem.ExistingSegments.Any()) + { + _logger.LogDebug("Media Segment provider {ProviderName} deleted all segments for {MediaPath}", provider.Name, baseItem.Path); continue; } - // delete existing media segments that were re-generated. - await existingSegments.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path); + var providerId = GetProviderId(provider.Name); + foreach (var segment in segments) + { + segment.ItemId = baseItem.Id; + await CreateSegmentAsync(segment, providerId).ConfigureAwait(false); + } } - - if (segments.Count == 0 && !requestItem.ExistingSegments.Any()) + catch (Exception ex) { - _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path); - continue; + _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path); } - else if (segments.Count == 0 && requestItem.ExistingSegments.Any()) - { - _logger.LogDebug("Media Segment provider {ProviderName} deleted all segments for {MediaPath}", provider.Name, baseItem.Path); - continue; - } - - _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path); - var providerId = GetProviderId(provider.Name); - foreach (var segment in segments) - { - segment.ItemId = baseItem.Id; - await CreateSegmentAsync(segment, providerId).ConfigureAwait(false); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path); } } } @@ -157,24 +159,34 @@ public class MediaSegmentManager : IMediaSegmentManager { ArgumentOutOfRangeException.ThrowIfLessThan(mediaSegment.EndTicks, mediaSegment.StartTicks); - using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - db.MediaSegments.Add(Map(mediaSegment, segmentProviderId)); - await db.SaveChangesAsync().ConfigureAwait(false); + var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (db.ConfigureAwait(false)) + { + db.MediaSegments.Add(Map(mediaSegment, segmentProviderId)); + await db.SaveChangesAsync().ConfigureAwait(false); + } + return mediaSegment; } /// 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(); + var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var sw = Stopwatch.StartNew(); - await FixBaseItemsAsync(context, sw, cancellationToken).ConfigureAwait(false); - sw.Reset(); - await FixChaptersAsync(context, sw, cancellationToken).ConfigureAwait(false); - sw.Reset(); - await FixBaseItemImageInfos(context, sw, cancellationToken).ConfigureAwait(false); + await FixBaseItemsAsync(context, sw, cancellationToken).ConfigureAwait(false); + sw.Reset(); + await FixChaptersAsync(context, sw, cancellationToken).ConfigureAwait(false); + sw.Reset(); + await FixBaseItemImageInfos(context, sw, cancellationToken).ConfigureAwait(false); + } } } 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 + + + + + + + +