aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Directory.Build.props5
-rw-r--r--Directory.Packages.props7
-rw-r--r--Jellyfin.Server.Implementations/Item/KeyframeRepository.cs13
-rw-r--r--Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs200
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixDates.cs19
-rw-r--r--Jellyfin.sln7
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs6
-rw-r--r--src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md9
-rw-r--r--src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs82
-rw-r--r--src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj17
10 files changed, 255 insertions, 110 deletions
diff --git a/Directory.Build.props b/Directory.Build.props
index 31ae8bfbe..900714171 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -19,4 +19,9 @@
<AdditionalFiles Include="$(MSBuildThisFileDirectory)/stylecop.json" />
</ItemGroup>
+ <!-- Custom Analyzers -->
+ <ItemGroup Condition=" '$(MSBuildProjectName)' != 'Jellyfin.CodeAnalysis' ">
+ <ProjectReference Include="$(MSBuildThisFileDirectory)src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj" OutputItemType="Analyzer" />
+ </ItemGroup>
+
</Project>
diff --git a/Directory.Packages.props b/Directory.Packages.props
index c07c06e20..f0c13b7c7 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -29,6 +29,9 @@
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.9" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.12.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.9" />
@@ -70,7 +73,7 @@
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
- <!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
+ <!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
<PackageVersion Include="SkiaSharp" Version="3.116.1" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.116.1" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
@@ -93,4 +96,4 @@
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
<PackageVersion Include="xunit" Version="2.9.3" />
</ItemGroup>
-</Project>
+</Project> \ No newline at end of file
diff --git a/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs
index 93c6f472e..438458c6b 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);
+ }
}
/// <inheritdoc />
diff --git a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs
index 97c9d79f5..d00c87463 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<MediaSegment> existingSegments;
if (forceOverwrite)
{
- existingSegments = Array.Empty<MediaSegment>().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<MediaSegment> existingSegments;
+ if (forceOverwrite)
+ {
+ existingSegments = Array.Empty<MediaSegment>().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;
}
/// <inheritdoc />
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);
+ }
}
/// <inheritdoc />
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);
+ }
}
/// <inheritdoc />
@@ -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 f112502b9..a5b11b11d 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 21ef13e72..fb1f2a2c2 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 359927d4d..6408f81ac 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 000000000..d23e3f9ed
--- /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 000000000..90c8dfeca
--- /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;
+
+/// <summary>
+/// Analyzer to detect sync disposal of async-created IAsyncDisposable objects.
+/// </summary>
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class AsyncDisposalPatternAnalyzer : DiagnosticAnalyzer
+{
+ /// <summary>
+ /// Diagnostic descriptor for sync disposal of async-created IAsyncDisposable objects.
+ /// </summary>
+ 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.");
+
+ /// <inheritdoc/>
+ public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [AsyncDisposableSyncDisposal];
+
+ /// <inheritdoc/>
+ 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 000000000..64d20e904
--- /dev/null
+++ b/src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>netstandard2.0</TargetFramework>
+ <LangVersion>latest</LangVersion>
+ <IncludeBuildOutput>false</IncludeBuildOutput>
+ <GeneratePackageOnBuild>false</GeneratePackageOnBuild>
+ <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
+ <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
+ </ItemGroup>
+
+</Project>