mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-09 09:18:46 +01:00
Merge remote-tracking branch 'upstream/master' into search-rebased
This commit is contained in:
@@ -1,17 +1,22 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Class BaseExtensions.
|
||||
/// Extension methods for the <see cref="Stream"/> class.
|
||||
/// </summary>
|
||||
public static class StreamExtensions
|
||||
{
|
||||
private const int StreamComparisonBufferSize = 81920;
|
||||
|
||||
/// <summary>
|
||||
/// Reads all lines in the <see cref="Stream" />.
|
||||
/// </summary>
|
||||
@@ -60,5 +65,172 @@ namespace Jellyfin.Extensions
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a stream is identical to a file on disk.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream to compare.</param>
|
||||
/// <param name="path">The file path to compare against.</param>
|
||||
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
|
||||
/// <returns>True if the stream and file are identical; otherwise false.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="stream"/> does not support seeking.</exception>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static async Task<bool> IsFileIdenticalAsync(this Stream stream, string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
if (!stream.CanSeek)
|
||||
{
|
||||
throw new ArgumentException("Stream must support seeking.", nameof(stream));
|
||||
}
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
try
|
||||
{
|
||||
stream.Position = 0;
|
||||
|
||||
var existingFileStream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: StreamComparisonBufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await using (existingFileStream.ConfigureAwait(false))
|
||||
{
|
||||
return await stream.IsStreamIdenticalAsync(existingFileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether two streams are identical.
|
||||
/// </summary>
|
||||
/// <param name="a">The first stream to compare.</param>
|
||||
/// <param name="b">The second stream to compare.</param>
|
||||
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
|
||||
/// <returns>True if the streams are identical; otherwise false.</returns>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static async Task<bool> IsStreamIdenticalAsync(this Stream a, Stream b, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(a);
|
||||
ArgumentNullException.ThrowIfNull(b);
|
||||
|
||||
if (ReferenceEquals(a, b))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a.CanSeek is var aCanSeek && aCanSeek)
|
||||
{
|
||||
a.Position = 0;
|
||||
}
|
||||
|
||||
if (b.CanSeek is var bCanSeek && bCanSeek)
|
||||
{
|
||||
b.Position = 0;
|
||||
}
|
||||
|
||||
if (aCanSeek && bCanSeek && b.Length != a.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// MemoryStreams only unlock a fast path if their underlying buffer is exposed via TryGetBuffer.
|
||||
var segmentA = a is MemoryStream streamA && streamA.TryGetBuffer(out var bufA) ? bufA : default;
|
||||
var segmentB = b is MemoryStream streamB && streamB.TryGetBuffer(out var bufB) ? bufB : default;
|
||||
|
||||
// Fast path A: both streams expose buffers, compare segments directly
|
||||
if (segmentA.Array is not null && segmentB.Array is not null)
|
||||
{
|
||||
return segmentA.AsSpan().SequenceEqual(segmentB.AsSpan());
|
||||
}
|
||||
|
||||
if (segmentB.Array is not null) // && segmentA.Array is null guaranteed by previous check
|
||||
{
|
||||
// swap so that segmentA is the non-null one, compared to b we need only one fast path B
|
||||
(segmentA, b) = (segmentB, a);
|
||||
}
|
||||
|
||||
if (segmentA.Array is not null) // either a was non-null, or b was non-null and was swapped there
|
||||
{
|
||||
// Fast path B: only one stream exposed a buffer, compare against the other chunk-by-chunk
|
||||
var bufferB = ArrayPool<byte>.Shared.Rent(StreamComparisonBufferSize);
|
||||
try
|
||||
{
|
||||
var memoryB = bufferB.AsMemory();
|
||||
int offset = 0;
|
||||
int bytesRead;
|
||||
while ((bytesRead = await b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
if (offset + bytesRead > segmentA.Count || !segmentA.AsSpan(offset, bytesRead).SequenceEqual(memoryB.Span[..bytesRead]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
offset += bytesRead;
|
||||
}
|
||||
|
||||
return offset == segmentA.Count;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(bufferB);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var bufferA = ArrayPool<byte>.Shared.Rent(StreamComparisonBufferSize);
|
||||
var bufferB = ArrayPool<byte>.Shared.Rent(StreamComparisonBufferSize);
|
||||
try
|
||||
{
|
||||
var memoryA = bufferA.AsMemory();
|
||||
var memoryB = bufferB.AsMemory();
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var taskA = a.ReadAtLeastAsync(memoryA, memoryA.Length, throwOnEndOfStream: false, cancellationToken).AsTask();
|
||||
var taskB = b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).AsTask();
|
||||
await Task.WhenAll(taskA, taskB).ConfigureAwait(false);
|
||||
|
||||
var bytesReadA = await taskA.ConfigureAwait(false);
|
||||
var bytesReadB = await taskB.ConfigureAwait(false);
|
||||
|
||||
if (bytesReadA != bytesReadB)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bytesReadA == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!memoryA.Span[..bytesReadA].SequenceEqual(memoryB.Span[..bytesReadB]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(bufferA);
|
||||
ArrayPool<byte>.Shared.Return(bufferB);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,27 +684,37 @@ namespace Jellyfin.LiveTv.Listings
|
||||
sdCode?.ToString() ?? "N/A",
|
||||
responseBody);
|
||||
|
||||
if (sdCode is SdErrorCode.InvalidUser or SdErrorCode.InvalidHash or SdErrorCode.AccountLocked or SdErrorCode.AccountExpired or SdErrorCode.PasswordRequired)
|
||||
if (sdCode is SdErrorCode.AccountExpired or SdErrorCode.InvalidHash or SdErrorCode.InvalidUser or SdErrorCode.AccountLocked or SdErrorCode.AppLocked or SdErrorCode.AccountInactive)
|
||||
{
|
||||
// Permanent account errors — disable SD for this server lifetime.
|
||||
_logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart", sdCode);
|
||||
_logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart.", sdCode);
|
||||
_tokens.Clear();
|
||||
_accountError = true;
|
||||
}
|
||||
else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.TemporaryLockout)
|
||||
else if (sdCode is SdErrorCode.ServiceOffline or SdErrorCode.ServiceBusy or SdErrorCode.AccountTempLock)
|
||||
{
|
||||
// Transient login errors — back off for 30 minutes, then allow retry.
|
||||
_logger.LogError("Schedules Direct transient error (code {SdCode}). Backing off for 30 minutes.", sdCode);
|
||||
_tokens.Clear();
|
||||
Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
|
||||
}
|
||||
else if (sdCode is SdErrorCode.MaxImageDownloads)
|
||||
else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.MaxIPAttempts)
|
||||
{
|
||||
// 24 hour bans - stop image and metadata requests until SD reset at 00:00 UTC.
|
||||
_logger.LogError("Schedules Direct service limit error (code {SdCode}). Disabling until SD reset.", sdCode);
|
||||
SetImageLimitHit();
|
||||
SetMetadataLimitHit();
|
||||
}
|
||||
else if (sdCode is SdErrorCode.MaxImageDownloads or SdErrorCode.MaxImageDownloadsTrial)
|
||||
{
|
||||
// Max image downloads — stop image requests until SD resets at 00:00 UTC.
|
||||
_logger.LogError("Schedules Direct image download limit hit (code {SdCode}). Disabling image acquisition until SD reset.", sdCode);
|
||||
SetImageLimitHit();
|
||||
}
|
||||
else if (sdCode is SdErrorCode.MaxScheduleRequests)
|
||||
{
|
||||
// Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC.
|
||||
_logger.LogError("Schedules Direct metadata download limit hit (code {SdCode}). Disabling metadata acquisition until SD reset.", sdCode);
|
||||
SetMetadataLimitHit();
|
||||
}
|
||||
else if (enableRetry
|
||||
|
||||
@@ -3,39 +3,59 @@
|
||||
namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
|
||||
|
||||
/// <summary>
|
||||
/// Schedules Direct API error codes.
|
||||
/// Schedules Direct API error codes. See https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#error-response for details.
|
||||
/// </summary>
|
||||
public enum SdErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Invalid user.
|
||||
/// Schedules Direct unavailable/out of service.
|
||||
/// </summary>
|
||||
InvalidUser = 4001,
|
||||
ServiceOffline = 3000,
|
||||
|
||||
/// <summary>
|
||||
/// Invalid password hash.
|
||||
/// Schedules Direct busy.
|
||||
/// </summary>
|
||||
InvalidHash = 4003,
|
||||
|
||||
/// <summary>
|
||||
/// Account locked or disabled.
|
||||
/// </summary>
|
||||
AccountLocked = 4004,
|
||||
ServiceBusy = 3001,
|
||||
|
||||
/// <summary>
|
||||
/// Account expired.
|
||||
/// </summary>
|
||||
AccountExpired = 4005,
|
||||
AccountExpired = 4001,
|
||||
|
||||
/// <summary>
|
||||
/// Token has expired.
|
||||
/// Invalid password hash.
|
||||
/// </summary>
|
||||
InvalidHash = 4002,
|
||||
|
||||
/// <summary>
|
||||
/// Invalid user or password.
|
||||
/// </summary>
|
||||
InvalidUser = 4003,
|
||||
|
||||
/// <summary>
|
||||
/// Account temporarily locked due to login failures.
|
||||
/// </summary>
|
||||
AccountTempLock = 4004,
|
||||
|
||||
/// <summary>
|
||||
/// Account permanently locked due to abuse.
|
||||
/// </summary>
|
||||
AccountLocked = 4005,
|
||||
|
||||
/// <summary>
|
||||
/// Token has expired. Request a new one.
|
||||
/// </summary>
|
||||
TokenExpired = 4006,
|
||||
|
||||
/// <summary>
|
||||
/// Password is required.
|
||||
/// Application locked out.
|
||||
/// </summary>
|
||||
PasswordRequired = 4008,
|
||||
AppLocked = 4007,
|
||||
|
||||
/// <summary>
|
||||
/// Account not active.
|
||||
/// </summary>
|
||||
AccountInactive = 4008,
|
||||
|
||||
/// <summary>
|
||||
/// Maximum login attempts exceeded.
|
||||
@@ -43,17 +63,32 @@ public enum SdErrorCode
|
||||
MaxLoginAttempts = 4009,
|
||||
|
||||
/// <summary>
|
||||
/// Temporary lockout.
|
||||
/// Maximum unique IP attempts reached.
|
||||
/// </summary>
|
||||
TemporaryLockout = 4010,
|
||||
MaxIPAttempts = 4010,
|
||||
|
||||
/// <summary>
|
||||
/// Lineup change maximum reached.
|
||||
/// </summary>
|
||||
MaxScheduleRequests = 4100,
|
||||
|
||||
/// <summary>
|
||||
/// Requested image not found.
|
||||
/// </summary>
|
||||
ImageNotFound = 5000,
|
||||
|
||||
/// <summary>
|
||||
/// Maximum image downloads reached for the day.
|
||||
/// </summary>
|
||||
MaxImageDownloads = 5002,
|
||||
|
||||
/// <summary>
|
||||
/// Trial specific maximum image downloads reached for the day.
|
||||
/// </summary>
|
||||
MaxImageDownloadsTrial = 5003,
|
||||
|
||||
/// <summary>
|
||||
/// Maximum schedule/metadata requests reached for the day.
|
||||
/// </summary>
|
||||
MaxScheduleRequests = 5003
|
||||
MaxInvalidImages = 5004
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user