From 645ae6bb99671ec8bd87c6cb78e6fa3d77063c55 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Thu, 28 May 2026 13:31:13 -0500 Subject: Use ReadAtLeastAsync to handle short-reads. Seeks to beginning of streams if CanSeek is true. Added remarks about stream position. Add test coverage for short-reads. Fix fast-path tests to actually test the fast path. Also fix class comment. --- src/Jellyfin.Extensions/StreamExtensions.cs | 38 +++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs index fb3fd2eac1..15b44d8f40 100644 --- a/src/Jellyfin.Extensions/StreamExtensions.cs +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -11,7 +11,7 @@ using System.Threading.Tasks; namespace Jellyfin.Extensions { /// - /// Class BaseExtensions. + /// Extension methods for the class. /// public static class StreamExtensions { @@ -74,7 +74,11 @@ namespace Jellyfin.Extensions /// The token to monitor for cancellation requests. /// True if the stream and file are identical; otherwise false. /// does not support seeking. - public static async Task IsFileIdenticalAsync(this Stream stream, string path, CancellationToken cancellationToken) + /// + /// The entire stream is compared against the file from the beginning (the position is reset to 0 on entry) + /// and restored to its original value after the call. + /// + public static async Task IsFileIdenticalAsync(this Stream stream, string path, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(stream); ArgumentException.ThrowIfNullOrEmpty(path); @@ -114,11 +118,31 @@ namespace Jellyfin.Extensions /// The second stream to compare. /// The token to monitor for cancellation requests. /// True if the streams are identical; otherwise false. - public static async Task IsStreamIdenticalAsync(this Stream a, Stream b, CancellationToken cancellationToken) + /// + /// Seekable streams are compared from the beginning (their position is reset to 0 on entry). + /// Non-seekable streams are compared from their current read position. Stream positions are not + /// restored after the call. + /// + public static async Task IsStreamIdenticalAsync(this Stream a, Stream b, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(a); ArgumentNullException.ThrowIfNull(b); + if (ReferenceEquals(a, b)) + { + return true; + } + + if (a.CanSeek) + { + a.Position = 0; + } + + if (b.CanSeek) + { + b.Position = 0; + } + if (a.CanSeek && b.CanSeek && b.Length != a.Length) { return false; @@ -145,9 +169,9 @@ namespace Jellyfin.Extensions var memoryB = bufferB.AsMemory(); int offset = 0; int bytesRead; - while ((bytesRead = await b.ReadAsync(memoryB, cancellationToken).ConfigureAwait(false)) > 0) + while ((bytesRead = await b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false)) > 0) { - if (!segmentA.AsSpan(offset, bytesRead).SequenceEqual(memoryB.Span[..bytesRead])) + if (offset + bytesRead > segmentA.Count || !segmentA.AsSpan(offset, bytesRead).SequenceEqual(memoryB.Span[..bytesRead])) { return false; } @@ -174,8 +198,8 @@ namespace Jellyfin.Extensions { cancellationToken.ThrowIfCancellationRequested(); - var bytesReadA = await a.ReadAsync(memoryA, cancellationToken).ConfigureAwait(false); - var bytesReadB = await b.ReadAsync(memoryB, cancellationToken).ConfigureAwait(false); + var bytesReadA = await a.ReadAtLeastAsync(memoryA, memoryA.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); + var bytesReadB = await b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); if (bytesReadA != bytesReadB) { -- cgit v1.2.3