fix: harden provider streaming and session lifecycle

This commit is contained in:
joshpatra committed 2026-08-02 00:19:12 -04:00
1 parent 3d816907b7
commit 640636d23a
39 files changed
+416 -271

No files matched your search

@@ -7,6 +7,16 @@ namespace allstarr.Tests;
public class RetryHelperTests
{
[Fact]
public async Task EnsureSuccessOrDispose_FailureDisposesResponse()
{
var content = new StringContent("failed");
var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) { Content = content };
Assert.Throws<HttpRequestException>(() => RetryHelper.EnsureSuccessOrDispose(response));
await Assert.ThrowsAsync<ObjectDisposedException>(() => content.ReadAsStringAsync());
}
[Fact]
public async Task RetryWithBackoffAsync_ShouldRetryOn503AndSucceed()
{
@@ -61,4 +71,21 @@ public class RetryHelperTests
Assert.Equal(1, attempts);
}
[Fact]
public async Task RetryWithBackoffAsync_CancellationStopsBackoff()
{
using var cancellation = new CancellationTokenSource();
var attempts = 0;
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
RetryHelper.RetryWithBackoffAsync<string>(() =>
{
attempts++;
cancellation.Cancel();
throw new HttpRequestException("temporary", null, HttpStatusCode.ServiceUnavailable);
}, NullLogger.Instance, initialDelayMs: 10_000, cancellationToken: cancellation.Token));
Assert.Equal(1, attempts);
}
}
@@ -173,6 +173,82 @@ public class JellyfinSessionManagerTests
Assert.DoesNotContain("/Sessions/Capabilities/Full", requestedPaths);
}
[Theory]
[InlineData(HttpStatusCode.ServiceUnavailable, true, true)]
[InlineData(HttpStatusCode.Unauthorized, false, false)]
public async Task EnsureSessionAsync_RefreshFailure_PreservesOnlyTransientSession(
HttpStatusCode refreshStatus,
bool expectedResult,
bool expectedSession)
{
var requests = 0;
var handler = new DelegateHttpMessageHandler((_, _) => Task.FromResult(
new HttpResponseMessage(Interlocked.Increment(ref requests) == 1
? HttpStatusCode.NoContent
: refreshStatus)));
var settings = CreateSettings();
using var manager = new JellyfinSessionManager(
CreateProxyService(handler, settings),
Options.Create(settings),
NullLogger<JellyfinSessionManager>.Instance);
var headers = CreateHeaders();
Assert.True(await manager.EnsureSessionAsync("dev-123", "Feishin", "Desktop", "1.0", headers));
Assert.Equal(expectedResult, await manager.EnsureSessionAsync("dev-123", "Feishin", "Desktop", "1.0", headers));
Assert.Equal(expectedSession, manager.HasSession("dev-123"));
Assert.Equal(2, requests);
}
[Fact]
public async Task RunKeepAlivePassAsync_DoesNotOverlapCapabilityRefreshes()
{
var requests = 0;
var refreshStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseRefresh = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var handler = new DelegateHttpMessageHandler(async (_, _) =>
{
if (Interlocked.Increment(ref requests) == 2)
{
refreshStarted.SetResult();
await releaseRefresh.Task;
}
return new HttpResponseMessage(HttpStatusCode.NoContent);
});
var settings = CreateSettings();
using var manager = new JellyfinSessionManager(
CreateProxyService(handler, settings),
Options.Create(settings),
NullLogger<JellyfinSessionManager>.Instance);
Assert.True(await manager.EnsureSessionAsync("dev-123", "Feishin", "Desktop", "1.0", CreateHeaders()));
var firstPass = manager.RunKeepAlivePassAsync();
await refreshStarted.Task.WaitAsync(TimeSpan.FromSeconds(2));
await manager.RunKeepAlivePassAsync().WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(2, requests);
releaseRefresh.SetResult();
await firstPass;
Assert.Equal(2, requests);
}
private static JellyfinSettings CreateSettings() => new()
{
Url = "http://127.0.0.1:1",
ApiKey = "server-api-key",
ClientName = "Allstarr",
DeviceName = "Allstarr",
DeviceId = "allstarr",
ClientVersion = "1.0"
};
private static HeaderDictionary CreateHeaders() => new()
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
};
private static JellyfinProxyService CreateProxyService(HttpMessageHandler handler, JellyfinSettings settings)
{
var httpClientFactory = new TestHttpClientFactory(handler);
@@ -169,7 +169,7 @@ public sealed class ProtocolProviderStreamingGatewayTests
}
[Fact]
public async Task OpenStream_UsesVerifiedRouterFallback()
public async Task OpenStream_UsesVerifiedRouterFallbackAndPreservesSuffixRange()
{
var first = Capability("deezer", ProviderOutcome<ProviderStreamLease>.Failure(
new ProviderError(ProviderErrorKind.TransientFailure)));
@@ -198,18 +198,23 @@ public sealed class ProtocolProviderStreamingGatewayTests
.Returns((ProviderRoutePlan<IProviderStreamingCapability> plan, int _, ProviderError _) =>
new ProviderFallbackDecision<IProviderStreamingCapability>(
ProviderFallbackDisposition.Advance, "fallback-transient-failure", plan.Candidates[1]));
var http = new HttpClientFactory();
var gateway = new ProtocolProviderGateway(
router.Object,
registry,
Mock.Of<IProviderRouteAccountResolver>(),
Mock.Of<IMusicMetadataService>(),
new HttpClientFactory());
http);
var stream = await gateway.OpenStreamAsync(
Context(), "deezer", "source-track", ProviderAudioQuality.Lossless, null);
Context(), "deezer", "source-track", ProviderAudioQuality.Lossless, "bytes=-4096");
Assert.NotNull(stream);
Assert.Equal("qobuz-lease", stream.Lease.LeaseId);
Assert.Equal("bytes=-4096", http.Range);
second.Verify(item => item.GetStreamLeaseAsync(
It.IsAny<ProviderExecutionContext>(),
It.Is<ProviderStreamLeaseRequest>(request => request.RangeStart == null)), Times.Once);
stream.Response.Dispose();
first.VerifyAll();
second.VerifyAll();
@@ -390,14 +395,20 @@ public sealed class ProtocolProviderStreamingGatewayTests
private sealed class HttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(new Handler());
public string? Range { get; private set; }
public HttpClient CreateClient(string name) => new(new Handler(request =>
Range = request.Headers.Range?.ToString()));
}
private sealed class Handler : HttpMessageHandler
private sealed class Handler(Action<HttpRequestMessage> inspect) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
CancellationToken cancellationToken)
{
inspect(request);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
}
+6 -3
View File
@@ -465,14 +465,17 @@ public class ConfigController : ControllerBase
};
// Try to restart by container name first, then by ID
var response = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null);
var restartResponse = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null);
if (!response.IsSuccessStatusCode)
if (!restartResponse.IsSuccessStatusCode)
{
// Try by container ID
response = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null);
restartResponse.Dispose();
restartResponse = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null);
}
using var response = restartResponse;
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Container restart initiated successfully");
+12 -12
View File
@@ -149,8 +149,8 @@ public class JellyfinAdminController : ControllerBase
AdminAuthSession session)
{
var playlistUrl = $"{_jellyfinSettings.Url}/Items/{jellyfinPlaylistId}?UserId={Uri.EscapeDataString(userId)}";
var playlistRequest = CreateJellyfinRequestForSession(HttpMethod.Get, playlistUrl, session);
var playlistResponse = await _jellyfinHttpClient.SendAsync(playlistRequest);
using var playlistRequest = CreateJellyfinRequestForSession(HttpMethod.Get, playlistUrl, session);
using var playlistResponse = await _jellyfinHttpClient.SendAsync(playlistRequest);
if (playlistResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
{
@@ -202,9 +202,9 @@ public class JellyfinAdminController : ControllerBase
{
var url = $"{_jellyfinSettings.Url}/Users";
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
using var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
var response = await _jellyfinHttpClient.SendAsync(request);
using var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
@@ -250,9 +250,9 @@ public class JellyfinAdminController : ControllerBase
{
var url = $"{_jellyfinSettings.Url}/Library/VirtualFolders";
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
using var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
var response = await _jellyfinHttpClient.SendAsync(request);
using var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
@@ -322,8 +322,8 @@ public class JellyfinAdminController : ControllerBase
url += $"&UserId={Uri.EscapeDataString(requestedUserId)}";
}
var request = CreateJellyfinRequestForSession(HttpMethod.Get, url, session);
var response = await _jellyfinHttpClient.SendAsync(request);
using var request = CreateJellyfinRequestForSession(HttpMethod.Get, url, session);
using var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
@@ -430,8 +430,8 @@ public class JellyfinAdminController : ControllerBase
// Admin fallback: if no configured user, try to get the first Jellyfin user.
if (session.IsAdministrator && string.IsNullOrEmpty(userId))
{
var usersRequest = CreateJellyfinRequestForSession(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users", session);
var usersResponse = await _jellyfinHttpClient.SendAsync(usersRequest);
using var usersRequest = CreateJellyfinRequestForSession(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users", session);
using var usersResponse = await _jellyfinHttpClient.SendAsync(usersRequest);
if (usersResponse.IsSuccessStatusCode)
{
@@ -451,9 +451,9 @@ public class JellyfinAdminController : ControllerBase
}
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistId}/Items?UserId={userId}&Fields=Path";
var request = CreateJellyfinRequestForSession(HttpMethod.Get, url, session);
using var request = CreateJellyfinRequestForSession(HttpMethod.Get, url, session);
var response = await _jellyfinHttpClient.SendAsync(request);
using var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode);
@@ -98,7 +98,7 @@ public partial class JellyfinController
_logger.LogDebug("📻 Playback START reported");
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
using var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
@@ -422,7 +422,7 @@ public partial class JellyfinController
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
using var doc = JsonDocument.Parse(body);
string? itemId = null;
long? positionTicks = null;
string? playSessionId = null;
@@ -1001,7 +1001,7 @@ public partial class JellyfinController
_logger.LogDebug("📤 Sending playback stop body ({BodyLength} bytes)", body.Length);
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
using var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
@@ -1190,7 +1190,7 @@ public partial class JellyfinController
_logger.LogDebug("📤 Original playback stop body length: {BodyLength} bytes", body.Length);
// Parse and fix the body - ensure IsPaused is false for a proper stop
var stopDoc = JsonDocument.Parse(body);
using var stopDoc = JsonDocument.Parse(body);
var stopInfo = new Dictionary<string, object?>();
foreach (var prop in stopDoc.RootElement.EnumerateObject())
+5 -1
View File
@@ -900,7 +900,11 @@ public partial class JellyfinController : ControllerBase
return retryTransientFailures
? await RetryHelper.RetryWithBackoffAsync(
Fetch, _logger, maxRetries: 3, initialDelayMs: 500)
Fetch,
_logger,
maxRetries: 3,
initialDelayMs: 500,
cancellationToken: HttpContext.RequestAborted)
: await Fetch();
},
MaximumArtworkBytes,
+2 -2
View File
@@ -553,11 +553,11 @@ public class PlaylistController : ControllerBase
url += $"?UserId={userId}";
}
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
using var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
_logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url);
var response = await _jellyfinHttpClient.SendAsync(request);
using var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
@@ -161,7 +161,7 @@ public class ScrobblingAdminController : ControllerBase
// Send POST request over HTTPS
var content = new FormUrlEncodedContent(parameters);
var response = await _httpClient.PostAsync("https://ws.audioscrobbler.com/2.0/", content);
using var response = await _httpClient.PostAsync("https://ws.audioscrobbler.com/2.0/", content);
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation("Last.fm authentication response status: {StatusCode}", response.StatusCode);
@@ -272,7 +272,7 @@ public class ScrobblingAdminController : ControllerBase
parameters["api_sig"] = signature;
var content = new FormUrlEncodedContent(parameters);
var response = await _httpClient.PostAsync("https://ws.audioscrobbler.com/2.0/", content, cancellationToken);
using var response = await _httpClient.PostAsync("https://ws.audioscrobbler.com/2.0/", content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
@@ -376,10 +376,10 @@ public class ScrobblingAdminController : ControllerBase
try
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.listenbrainz.org/1/validate-token");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.listenbrainz.org/1/validate-token");
httpRequest.Headers.Add("Authorization", $"Token {request.UserToken}");
var response = await _httpClient.SendAsync(httpRequest);
using var response = await _httpClient.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
@@ -390,7 +390,7 @@ public class ScrobblingAdminController : ControllerBase
"Check the user token and save a replacement if needed.");
}
var jsonDoc = System.Text.Json.JsonDocument.Parse(responseBody);
using var jsonDoc = System.Text.Json.JsonDocument.Parse(responseBody);
var valid = jsonDoc.RootElement.GetProperty("valid").GetBoolean();
if (!valid)
@@ -435,10 +435,10 @@ public class ScrobblingAdminController : ControllerBase
try
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.listenbrainz.org/1/validate-token");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.listenbrainz.org/1/validate-token");
httpRequest.Headers.Add("Authorization", $"Token {token}");
var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
@@ -449,7 +449,7 @@ public class ScrobblingAdminController : ControllerBase
"Check the user token and save a replacement if needed.");
}
var jsonDoc = System.Text.Json.JsonDocument.Parse(responseBody);
using var jsonDoc = System.Text.Json.JsonDocument.Parse(responseBody);
var valid = jsonDoc.RootElement.GetProperty("valid").GetBoolean();
if (!valid)
+2 -2
View File
@@ -429,7 +429,7 @@ public class SubsonicController : ControllerBase
if (format == "json" || navidromeResult.ContentType?.Contains("json") == true)
{
var jsonDoc = JsonDocument.Parse(navidromeContent);
using var jsonDoc = JsonDocument.Parse(navidromeContent);
if (jsonDoc.RootElement.TryGetProperty("subsonic-response", out var response) &&
response.TryGetProperty("artist", out var artistElement))
{
@@ -593,7 +593,7 @@ public class SubsonicController : ControllerBase
if (format == "json" || navidromeResult.ContentType?.Contains("json") == true)
{
var jsonDoc = JsonDocument.Parse(navidromeContent);
using var jsonDoc = JsonDocument.Parse(navidromeContent);
if (jsonDoc.RootElement.TryGetProperty("subsonic-response", out var response) &&
response.TryGetProperty("album", out var albumElement))
{
@@ -629,7 +629,7 @@ public sealed class ProtocolProviderGateway(
}
var lease = outcome.RequireValue();
var request = new HttpRequestMessage(HttpMethod.Get, lease.ProtectedSourceUri);
using var request = new HttpRequestMessage(HttpMethod.Get, lease.ProtectedSourceUri);
if (rangeHeader != null && lease.SupportsByteRanges)
{
request.Headers.Range = RangeHeaderValue.Parse(rangeHeader);
@@ -638,7 +638,6 @@ public sealed class ProtocolProviderGateway(
request,
HttpCompletionOption.ResponseHeadersRead,
protocol.CancellationToken);
request.Dispose();
return new ProtocolProviderStream(response, lease);
}
return null;
@@ -860,11 +859,7 @@ public sealed class ProtocolProviderGateway(
throw new InvalidOperationException("Only one valid byte range may be requested.");
}
var range = parsed.Ranges.Single();
if (!range.From.HasValue)
{
throw new InvalidOperationException("Suffix byte ranges are not supported for provider leases.");
}
return range.From.Value;
return range.From;
}
private static void ThrowRouteFailure(ProviderError error) => throw error.Kind switch
@@ -289,7 +289,7 @@ public class AppleMusicDownloadService : BaseDownloadService
Logger.LogInformation("Downloading Apple Music track {TrackId} at quality {Quality} from sidecar...", trackId, quality);
using var req = new HttpRequestMessage(HttpMethod.Get, streamUrl);
var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
using var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken);
+5 -5
View File
@@ -44,11 +44,11 @@ public class OdesliService
_logger.LogDebug("🔗 Converting Tidal track {TidalId} to Spotify ID via Odesli", tidalTrackId);
var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken);
using var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken);
if (odesliResponse.IsSuccessStatusCode)
{
var odesliJson = await odesliResponse.Content.ReadAsStringAsync(cancellationToken);
var odesliDoc = JsonDocument.Parse(odesliJson);
using var odesliDoc = JsonDocument.Parse(odesliJson);
// Extract Spotify track ID from the Spotify URL
if (odesliDoc.RootElement.TryGetProperty("linksByPlatform", out var platforms) &&
@@ -103,11 +103,11 @@ public class OdesliService
_logger.LogDebug("🔗 Converting URL to Spotify ID via Odesli: {Url}", musicUrl);
var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken);
using var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken);
if (odesliResponse.IsSuccessStatusCode)
{
var odesliJson = await odesliResponse.Content.ReadAsStringAsync(cancellationToken);
var odesliDoc = JsonDocument.Parse(odesliJson);
using var odesliDoc = JsonDocument.Parse(odesliJson);
// Extract Spotify track ID from the Spotify URL
if (odesliDoc.RootElement.TryGetProperty("linksByPlatform", out var platforms) &&
@@ -171,7 +171,7 @@ public class OdesliService
var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(sourceUrl)}&userCountry=US";
_logger.LogDebug("🔗 Odesli: Translating {Url} to platform {Platform}", sourceUrl, targetPlatform);
var response = await _httpClient.GetAsync(odesliUrl, cancellationToken);
using var response = await _httpClient.GetAsync(odesliUrl, cancellationToken);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
+21 -4
View File
@@ -8,6 +8,19 @@ namespace allstarr.Services.Common;
/// </summary>
public static class RetryHelper
{
internal static HttpResponseMessage EnsureSuccessOrDispose(HttpResponseMessage response)
{
try
{
return response.EnsureSuccessStatusCode();
}
catch
{
response.Dispose();
throw;
}
}
/// <summary>
/// Executes an async action with exponential backoff retry logic.
/// Retries on HTTP 503 (Service Unavailable) and 429 (Too Many Requests).
@@ -22,12 +35,15 @@ public static class RetryHelper
Func<Task<T>> action,
ILogger logger,
int maxRetries = 3,
int initialDelayMs = 1000)
int initialDelayMs = 1000,
CancellationToken cancellationToken = default)
{
Exception? lastException = null;
for (int attempt = 0; attempt < maxRetries; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
return await action();
@@ -43,7 +59,7 @@ public static class RetryHelper
logger.LogWarning(
"Retry attempt {Attempt}/{MaxRetries} after {Delay}ms ({Message})",
attempt + 1, maxRetries, delay, ex.Message);
await Task.Delay(delay);
await Task.Delay(delay, cancellationToken);
}
}
catch
@@ -62,12 +78,13 @@ public static class RetryHelper
Func<Task> action,
ILogger logger,
int maxRetries = 3,
int initialDelayMs = 1000)
int initialDelayMs = 1000,
CancellationToken cancellationToken = default)
{
await RetryWithBackoffAsync(async () =>
{
await action();
return true;
}, logger, maxRetries, initialDelayMs);
}, logger, maxRetries, initialDelayMs, cancellationToken);
}
}
@@ -112,16 +112,15 @@ public class DeezerDownloadService : BaseDownloadService
outputPath = PathHelper.ResolveUniquePath(outputPath);
// Download the encrypted file
var response = await RetryHelper.RetryWithBackoffAsync(async () =>
using var response = await RetryHelper.RetryWithBackoffAsync(async () =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*");
var res = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
return res;
}, Logger);
return RetryHelper.EnsureSuccessOrDispose(res);
}, Logger, cancellationToken: cancellationToken);
// Download and decrypt
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
@@ -203,16 +202,15 @@ public class DeezerDownloadService : BaseDownloadService
}
// Download the encrypted file
var response = await RetryHelper.RetryWithBackoffAsync(async () =>
using var response = await RetryHelper.RetryWithBackoffAsync(async () =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*");
var res = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
return res;
}, Logger);
return RetryHelper.EnsureSuccessOrDispose(res);
}, Logger, cancellationToken: cancellationToken);
// Download and decrypt (Deezer uses Blowfish CBC encryption)
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
@@ -294,11 +292,11 @@ public class DeezerDownloadService : BaseDownloadService
request.Headers.Add("Cookie", $"arl={arl}");
request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(request);
using var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("results", out var results) &&
results.TryGetProperty("checkForm", out var checkForm))
@@ -331,11 +329,11 @@ public class DeezerDownloadService : BaseDownloadService
return await QueueRequestAsync(async () =>
{
// Get track info
var trackResponse = await _httpClient.GetAsync($"{DeezerApiBase}/track/{trackId}", cancellationToken);
using var trackResponse = await _httpClient.GetAsync($"{DeezerApiBase}/track/{trackId}", cancellationToken);
trackResponse.EnsureSuccessStatusCode();
var trackJson = await trackResponse.Content.ReadAsStringAsync(cancellationToken);
var trackDoc = JsonDocument.Parse(trackJson);
using var trackDoc = JsonDocument.Parse(trackJson);
if (!trackDoc.RootElement.TryGetProperty("track_token", out var trackTokenElement))
{
@@ -374,11 +372,11 @@ public class DeezerDownloadService : BaseDownloadService
using (mediaHttpRequest)
{
var mediaResponse = await _httpClient.SendAsync(mediaHttpRequest, cancellationToken);
using var mediaResponse = await _httpClient.SendAsync(mediaHttpRequest, cancellationToken);
mediaResponse.EnsureSuccessStatusCode();
var mediaJson = await mediaResponse.Content.ReadAsStringAsync(cancellationToken);
var mediaDoc = JsonDocument.Parse(mediaJson);
using var mediaDoc = JsonDocument.Parse(mediaJson);
if (!mediaDoc.RootElement.TryGetProperty("data", out var data) ||
data.GetArrayLength() == 0)
@@ -76,12 +76,12 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
try
{
var url = BuildRankedSearchUrl("track", query, limit);
var response = await GetAsync(url, cancellationToken);
using var response = await GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Song>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var songs = new List<Song>();
if (result.RootElement.TryGetProperty("data", out var data))
@@ -115,7 +115,7 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
{
var normalizedIsrc = isrc.Trim();
var url = $"{BaseUrl}/track/isrc:{Uri.EscapeDataString(normalizedIsrc)}";
var response = await GetAsync(url, cancellationToken);
using var response = await GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -179,12 +179,12 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
try
{
var url = BuildRankedSearchUrl("album", query, limit);
var response = await GetAsync(url, cancellationToken);
using var response = await GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Album>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var albums = new List<Album>();
if (result.RootElement.TryGetProperty("data", out var data))
@@ -241,12 +241,12 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
try
{
var url = BuildRankedSearchUrl("artist", query, limit);
var response = await GetAsync(url, cancellationToken);
using var response = await GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Artist>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
if (result.RootElement.TryGetProperty("data", out var data))
@@ -330,12 +330,13 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null;
var url = $"{BaseUrl}/track/{externalId}";
var response = await GetAsync(url, cancellationToken);
using var response = await GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var track = JsonDocument.Parse(json).RootElement;
using var trackDocument = JsonDocument.Parse(json);
var track = trackDocument.RootElement;
if (track.TryGetProperty("error", out _)) return null;
@@ -350,11 +351,12 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
try
{
var albumUrl = $"{BaseUrl}/album/{albumId}";
var albumResponse = await GetAsync(albumUrl, cancellationToken);
using var albumResponse = await GetAsync(albumUrl, cancellationToken);
if (albumResponse.IsSuccessStatusCode)
{
var albumJson = await albumResponse.Content.ReadAsStringAsync(cancellationToken);
var albumData = JsonDocument.Parse(albumJson).RootElement;
using var albumDocument = JsonDocument.Parse(albumJson);
var albumData = albumDocument.RootElement;
// Genre
if (albumData.TryGetProperty("genres", out var genres) &&
@@ -422,7 +424,7 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null;
var url = $"{BaseUrl}/album/{externalId}";
var response = await GetAsync(url, cancellationToken);
using var response = await GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
@@ -491,12 +493,13 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null;
var url = $"{BaseUrl}/artist/{externalId}";
var response = await GetAsync(url, cancellationToken);
using var response = await GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var artist = JsonDocument.Parse(json).RootElement;
using var artistDocument = JsonDocument.Parse(json);
var artist = artistDocument.RootElement;
if (artist.TryGetProperty("error", out _)) return null;
@@ -521,12 +524,12 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List<Song>();
var url = $"{BaseUrl}/artist/{externalId}/top?limit=50";
var response = await GetAsync(url, cancellationToken);
using var response = await GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Song>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var tracks = new List<Song>();
if (result.RootElement.TryGetProperty("data", out var data))
@@ -809,12 +812,12 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
try
{
var url = BuildRankedSearchUrl("playlist", query, limit);
var response = await GetAsync(url, cancellationToken);
using var response = await GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var playlists = new List<ExternalPlaylist>();
if (result.RootElement.TryGetProperty("data", out var data))
@@ -840,12 +843,13 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
try
{
var url = $"{BaseUrl}/playlist/{externalId}";
var response = await GetAsync(url, cancellationToken);
using var response = await GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var playlistElement = JsonDocument.Parse(json).RootElement;
using var playlistDocument = JsonDocument.Parse(json);
var playlistElement = playlistDocument.RootElement;
if (playlistElement.TryGetProperty("error", out _)) return null;
@@ -864,12 +868,13 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
try
{
var url = $"{BaseUrl}/playlist/{externalId}";
var response = await GetAsync(url, cancellationToken);
using var response = await GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Song>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var playlistElement = JsonDocument.Parse(json).RootElement;
using var playlistDocument = JsonDocument.Parse(json);
var playlistElement = playlistDocument.RootElement;
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
@@ -950,7 +955,7 @@ public class DeezerMetadataService : TrackParserBase, IConcreteMetadataService
while (IsOfficialDeezerApiUrl(pageUrl) && seenPageUrls.Add(pageUrl!))
{
var response = await GetAsync(pageUrl!, cancellationToken);
using var response = await GetAsync(pageUrl!, cancellationToken);
if (!response.IsSuccessStatusCode)
{
break;
@@ -230,7 +230,7 @@ public class JellyfinProxyService
LogOutboundRequest(HttpMethod.Get, url);
var response = await _httpClient.SendAsync(request);
using var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
@@ -461,7 +461,7 @@ public class JellyfinProxyService
_logger.LogTrace("{Method} to Jellyfin: {Url}, body length: {Length} bytes", method, safeUrl, body.Length);
}
var response = await _httpClient.SendAsync(request);
using var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
@@ -506,7 +506,7 @@ public class JellyfinProxyService
/// <summary>
/// Sends a GET request and returns raw bytes (for images, audio streams).
/// WARNING: This loads entire response into memory - use StreamAsync for large files!
/// WARNING: This loads the entire response into memory and is intended only for bounded assets.
/// </summary>
public async Task<(byte[] Body, string? ContentType)> GetBytesAsync(string endpoint, Dictionary<string, string>? queryParams = null)
{
@@ -517,43 +517,15 @@ public class JellyfinProxyService
LogOutboundRequest(HttpMethod.Get, url);
var response = await _httpClient.SendAsync(request);
using var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString();
// Trigger GC for large files to prevent memory leaks
if (body.Length > 1024 * 1024) // 1MB threshold
{
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
}
return (body, contentType);
}
/// <summary>
/// Streams content directly without loading into memory (for large files like audio).
/// </summary>
public async Task<(Stream Stream, string? ContentType, long? ContentLength)> GetStreamAsync(string endpoint, Dictionary<string, string>? queryParams = null)
{
var url = BuildUrl(endpoint, queryParams);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Authorization", GetAuthorizationHeader());
LogOutboundRequest(HttpMethod.Get, url);
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync();
var contentType = response.Content.Headers.ContentType?.ToString();
var contentLength = response.Content.Headers.ContentLength;
return (stream, contentType, contentLength);
}
/// <summary>
/// Sends a DELETE request to the Jellyfin server.
/// Forwards client headers for authentication passthrough.
@@ -1193,7 +1165,7 @@ public class JellyfinProxyService
LogOutboundRequest(HttpMethod.Get, url);
var response = await _httpClient.SendAsync(request);
using var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
var content = await response.Content.ReadAsStringAsync();
@@ -22,6 +22,7 @@ public class JellyfinSessionManager : IDisposable
private readonly ConcurrentDictionary<string, SemaphoreSlim> _sessionInitLocks = new();
private readonly ConcurrentDictionary<string, byte> _proxiedWebSocketConnections = new();
private readonly Timer _keepAliveTimer;
private int _keepAliveRunning;
public JellyfinSessionManager(
JellyfinProxyService proxyService,
@@ -68,14 +69,19 @@ public class JellyfinSessionManager : IDisposable
// Refresh capabilities to keep session alive only for sessions that Allstarr
// is synthesizing itself. Native proxied websocket sessions should be left
// entirely under Jellyfin's control.
var refreshOk = await PostCapabilitiesAsync(headers);
if (!refreshOk)
var refreshResult = await PostCapabilitiesAsync(headers);
if (refreshResult == CapabilitiesPostResult.Unauthorized)
{
// Token expired - remove the stale session
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
}
if (refreshResult == CapabilitiesPostResult.Failed)
{
_logger.LogWarning("Could not refresh capabilities for device {DeviceId}; preserving the existing session", deviceId);
}
}
return true;
@@ -88,11 +94,10 @@ public class JellyfinSessionManager : IDisposable
// Post session capabilities to Jellyfin only when Allstarr is creating a
// synthetic session. If the real client already has a proxied websocket,
// re-posting capabilities can overwrite its remote-control state.
var createOk = await PostCapabilitiesAsync(headers);
if (!createOk)
var createResult = await PostCapabilitiesAsync(headers);
if (createResult != CapabilitiesPostResult.Success)
{
// Token expired or invalid - client needs to re-authenticate
_logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId);
_logger.LogError("Failed to create session for {DeviceId}: {Result}", deviceId, createResult);
return false;
}
@@ -181,9 +186,9 @@ public class JellyfinSessionManager : IDisposable
/// <summary>
/// Posts session capabilities to Jellyfin.
/// Returns true if successful, false if token expired (401).
/// Distinguishes an expired token from a transient upstream failure.
/// </summary>
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
private async Task<CapabilitiesPostResult> PostCapabilitiesAsync(IHeaderDictionary headers)
{
var capabilities = new
{
@@ -205,18 +210,18 @@ public class JellyfinSessionManager : IDisposable
if (statusCode == 204 || statusCode == 200)
{
_logger.LogTrace("Posted capabilities successfully ({StatusCode})", statusCode);
return true;
return CapabilitiesPostResult.Success;
}
else if (statusCode == 401)
{
// Token expired - this is expected, client needs to re-authenticate
_logger.LogWarning("Capabilities returned 401 (token expired) - client should re-authenticate");
return false;
return CapabilitiesPostResult.Unauthorized;
}
else
{
_logger.LogDebug("Capabilities post returned {StatusCode}", statusCode);
return false;
return CapabilitiesPostResult.Failed;
}
}
@@ -695,61 +700,78 @@ public class JellyfinSessionManager : IDisposable
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
/// Removes sessions with expired tokens (401 responses).
/// </summary>
private async void KeepSessionsAlive(object? state)
{
var now = DateTime.UtcNow;
var activeSessions = _sessions.Values.Where(s => now - s.LastActivity < TimeSpan.FromMinutes(5)).ToList();
private void KeepSessionsAlive(object? state) => _ = RunKeepAlivePassAsync();
if (activeSessions.Count == 0)
internal async Task RunKeepAlivePassAsync()
{
if (Interlocked.Exchange(ref _keepAliveRunning, 1) != 0)
{
return;
}
_logger.LogTrace("Keeping {Count} sessions alive", activeSessions.Count);
var expiredSessions = new List<string>();
foreach (var session in activeSessions)
try
{
try
var now = DateTime.UtcNow;
var activeSessions = _sessions.Values.Where(s => now - s.LastActivity < TimeSpan.FromMinutes(5)).ToList();
if (activeSessions.Count == 0)
{
session.HasProxiedWebSocket = HasProxiedWebSocket(session.DeviceId);
if (session.HasProxiedWebSocket)
return;
}
_logger.LogTrace("Keeping {Count} sessions alive", activeSessions.Count);
var expiredSessions = new List<string>();
foreach (var session in activeSessions)
{
try
{
continue;
session.HasProxiedWebSocket = HasProxiedWebSocket(session.DeviceId);
if (session.HasProxiedWebSocket)
{
continue;
}
var result = await PostCapabilitiesAsync(session.Headers);
if (result == CapabilitiesPostResult.Unauthorized)
{
_logger.LogWarning("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
expiredSessions.Add(session.DeviceId);
}
else if (result == CapabilitiesPostResult.Failed)
{
_logger.LogWarning("Capability keep-alive failed for device {DeviceId}; preserving the session", session.DeviceId);
}
}
// Post capabilities again to keep session alive
// If this returns false (401), the token has expired
var success = await PostCapabilitiesAsync(session.Headers);
if (!success)
catch (Exception ex)
{
_logger.LogWarning("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
expiredSessions.Add(session.DeviceId);
_logger.LogError(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
}
}
catch (Exception ex)
foreach (var deviceId in expiredSessions)
{
_logger.LogError(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
_logger.LogWarning("Removing session with expired token: {DeviceId}", deviceId);
await RemoveSessionAsync(deviceId);
}
// This balances cleaning up finished sessions with allowing brief pauses/network issues.
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
foreach (var stale in staleSessions)
{
_logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
await RemoveSessionAsync(stale.Key);
}
}
// Remove sessions with expired tokens
foreach (var deviceId in expiredSessions)
catch (Exception ex)
{
_logger.LogWarning("Removing session with expired token: {DeviceId}", deviceId);
await RemoveSessionAsync(deviceId);
_logger.LogError(ex, "Unexpected error during Jellyfin session keep-alive");
}
// Clean up stale sessions after 3 minutes of inactivity
// This balances cleaning up finished sessions with allowing brief pauses/network issues
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
foreach (var stale in staleSessions)
finally
{
_logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
await RemoveSessionAsync(stale.Key);
Volatile.Write(ref _keepAliveRunning, 0);
}
}
@@ -788,6 +810,13 @@ public class JellyfinSessionManager : IDisposable
long PositionTicks,
DateTime LastActivity);
private enum CapabilitiesPostResult
{
Success,
Unauthorized,
Failed
}
public void Dispose()
{
_keepAliveTimer?.Dispose();
@@ -33,7 +33,7 @@ public class JellyfinStartupValidator : BaseStartupValidator
try
{
var publicInfoUrl = $"{settings.Url.TrimEnd('/')}/System/Info/Public";
var response = await _httpClient.GetAsync(publicInfoUrl, cancellationToken);
using var response = await _httpClient.GetAsync(publicInfoUrl, cancellationToken);
if (response.IsSuccessStatusCode)
{
@@ -154,7 +154,7 @@ public class LocalLibraryService : ILocalLibraryService
_logger.LogInformation("Triggering Subsonic library scan...");
var response = await _httpClient.GetAsync(url);
using var response = await _httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
@@ -183,12 +183,12 @@ public class LocalLibraryService : ILocalLibraryService
// when called from localhost.
var url = $"{_subsonicSettings.Url}/rest/getScanStatus?f=json";
var response = await _httpClient.GetAsync(url);
using var response = await _httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("subsonic-response", out var subsonicResponse) &&
subsonicResponse.TryGetProperty("scanStatus", out var scanStatus))
+4 -4
View File
@@ -93,7 +93,7 @@ public class LrclibService
_logger.LogDebug("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length);
var searchResponse = await _httpClient.GetAsync(searchUrl);
using var searchResponse = await _httpClient.GetAsync(searchUrl);
if (searchResponse.IsSuccessStatusCode)
{
@@ -178,7 +178,7 @@ public class LrclibService
_logger.LogDebug("Trying exact match from LRCLIB: {Url}", exactUrl);
var exactResponse = await _httpClient.GetAsync(exactUrl);
using var exactResponse = await _httpClient.GetAsync(exactUrl);
if (exactResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
{
@@ -321,7 +321,7 @@ public class LrclibService
$"album_name={Uri.EscapeDataString(albumName)}&" +
$"duration={durationSeconds}";
var response = await _httpClient.GetAsync(url);
using var response = await _httpClient.GetAsync(url);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
@@ -377,7 +377,7 @@ public class LrclibService
try
{
var url = $"{BaseUrl}/get/{id}";
var response = await _httpClient.GetAsync(url);
using var response = await _httpClient.GetAsync(url);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
@@ -79,7 +79,7 @@ public class LyricsPlusService
_logger.LogDebug("Fetching lyrics from LyricsPlus: {Url}", url);
var response = await _httpClient.GetAsync(url);
using var response = await _httpClient.GetAsync(url);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
@@ -47,7 +47,7 @@ public class LyricsStartupValidator : BaseStartupValidator
{
var url = $"https://lrclib.net/api/get?artist_name={Uri.EscapeDataString(TestArtist)}&track_name={Uri.EscapeDataString(TestSongTitle)}&album_name={Uri.EscapeDataString(TestAlbum)}&duration={TestDuration}";
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
@@ -81,12 +81,12 @@ public class LyricsStartupValidator : BaseStartupValidator
var url = $"{_spotifySettings.LyricsApiUrl}/?trackid={TestSpotifyId}&format=id3";
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
using var doc = JsonDocument.Parse(json);
var hasError = doc.RootElement.TryGetProperty("error", out var error) && error.GetBoolean();
@@ -61,7 +61,7 @@ public class SpotifyLyricsService
_logger.LogDebug("Fetching lyrics from sidecar API: {Url}", url);
var response = await _httpClient.GetAsync(url);
using var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
@@ -74,7 +74,7 @@ public class MusicBrainzService
var url = $"{_settings.BaseUrl}/isrc/{isrc}?fmt=json&inc=artists+releases+release-groups+genres+tags";
_logger.LogDebug("MusicBrainz ISRC lookup: {Url}", url);
var response = await _httpClient.GetAsync(url);
using var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
@@ -141,7 +141,7 @@ public class MusicBrainzService
_logger.LogDebug("MusicBrainz search: {Url}", url);
var response = await _httpClient.GetAsync(url);
using var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
@@ -199,7 +199,7 @@ public class MusicBrainzService
var url = $"{_settings.BaseUrl}/recording/{mbid}?fmt=json&inc=artists+releases+release-groups+genres+tags";
_logger.LogDebug("MusicBrainz MBID lookup: {Url}", url);
var response = await _httpClient.GetAsync(url);
using var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
@@ -116,7 +116,7 @@ public class QobuzBundleService
/// </summary>
private async Task<string> GetBundleUrlAsync()
{
var response = await _httpClient.GetAsync(LoginPageUrl);
using var response = await _httpClient.GetAsync(LoginPageUrl);
response.EnsureSuccessStatusCode();
var html = await response.Content.ReadAsStringAsync();
@@ -135,7 +135,7 @@ public class QobuzBundleService
/// </summary>
private async Task<string> DownloadBundleAsync(string bundleUrl)
{
var response = await _httpClient.GetAsync(bundleUrl);
using var response = await _httpClient.GetAsync(bundleUrl);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
@@ -113,12 +113,11 @@ public class QobuzDownloadService : BaseDownloadService
outputPath = PathHelper.ResolveUniquePath(outputPath);
// Download the file (Qobuz files are NOT encrypted like Deezer)
var response = await RetryHelper.RetryWithBackoffAsync(async () =>
using var response = await RetryHelper.RetryWithBackoffAsync(async () =>
{
var res = await _httpClient.GetAsync(downloadInfo.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
return res;
}, Logger);
return RetryHelper.EnsureSuccessOrDispose(res);
}, Logger, cancellationToken: cancellationToken);
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
@@ -220,12 +219,11 @@ public class QobuzDownloadService : BaseDownloadService
}
// Download the file (Qobuz files are NOT encrypted like Deezer)
var response = await RetryHelper.RetryWithBackoffAsync(async () =>
using var response = await RetryHelper.RetryWithBackoffAsync(async () =>
{
var res = await _httpClient.GetAsync(downloadInfo.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
return res;
}, Logger);
return RetryHelper.EnsureSuccessOrDispose(res);
}, Logger, cancellationToken: cancellationToken);
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
@@ -345,7 +343,7 @@ public class QobuzDownloadService : BaseDownloadService
request.Headers.Add("X-User-Auth-Token", _userAuthToken);
}
var response = await _httpClient.SendAsync(request, cancellationToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
@@ -355,7 +353,7 @@ public class QobuzDownloadService : BaseDownloadService
throw new HttpRequestException($"Response status code does not indicate success: {response.StatusCode} ({response.ReasonPhrase})");
}
var doc = JsonDocument.Parse(responseBody);
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
if (!root.TryGetProperty("url", out var urlElement) || string.IsNullOrEmpty(urlElement.GetString()))
+25 -20
View File
@@ -55,11 +55,11 @@ public class QobuzMetadataService : TrackParserBase, IConcreteMetadataService
var appId = await _bundleService.GetAppIdAsync();
var url = $"{BaseUrl}track/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
var response = await GetWithAuthAsync(url, cancellationToken);
using var response = await GetWithAuthAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Song>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var songs = new List<Song>();
if (result.RootElement.TryGetProperty("tracks", out var tracks) &&
@@ -101,11 +101,11 @@ public class QobuzMetadataService : TrackParserBase, IConcreteMetadataService
var appId = await _bundleService.GetAppIdAsync();
var url = $"{BaseUrl}album/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
var response = await GetWithAuthAsync(url, cancellationToken);
using var response = await GetWithAuthAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Album>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var albums = new List<Album>();
if (result.RootElement.TryGetProperty("albums", out var albumsData) &&
@@ -133,11 +133,11 @@ public class QobuzMetadataService : TrackParserBase, IConcreteMetadataService
var appId = await _bundleService.GetAppIdAsync();
var url = $"{BaseUrl}artist/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
var response = await GetWithAuthAsync(url, cancellationToken);
using var response = await GetWithAuthAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Artist>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
if (result.RootElement.TryGetProperty("artists", out var artistsData) &&
@@ -189,11 +189,12 @@ public class QobuzMetadataService : TrackParserBase, IConcreteMetadataService
var appId = await _bundleService.GetAppIdAsync();
var url = $"{BaseUrl}track/get?track_id={externalId}&app_id={appId}";
var response = await GetWithAuthAsync(url, cancellationToken);
using var response = await GetWithAuthAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var track = JsonDocument.Parse(json).RootElement;
using var trackDocument = JsonDocument.Parse(json);
var track = trackDocument.RootElement;
if (track.TryGetProperty("error", out _)) return null;
@@ -234,11 +235,12 @@ public class QobuzMetadataService : TrackParserBase, IConcreteMetadataService
var appId = await _bundleService.GetAppIdAsync();
var url = $"{BaseUrl}album/get?album_id={externalId}&app_id={appId}";
var response = await GetWithAuthAsync(url, cancellationToken);
using var response = await GetWithAuthAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var albumElement = JsonDocument.Parse(json).RootElement;
using var albumDocument = JsonDocument.Parse(json);
var albumElement = albumDocument.RootElement;
if (albumElement.TryGetProperty("error", out _)) return null;
@@ -279,11 +281,12 @@ public class QobuzMetadataService : TrackParserBase, IConcreteMetadataService
var appId = await _bundleService.GetAppIdAsync();
var url = $"{BaseUrl}artist/get?artist_id={externalId}&app_id={appId}";
var response = await GetWithAuthAsync(url, cancellationToken);
using var response = await GetWithAuthAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var artist = JsonDocument.Parse(json).RootElement;
using var artistDocument = JsonDocument.Parse(json);
var artist = artistDocument.RootElement;
if (artist.TryGetProperty("error", out _)) return null;
@@ -312,11 +315,11 @@ public class QobuzMetadataService : TrackParserBase, IConcreteMetadataService
{
var url = $"{BaseUrl}artist/get?artist_id={externalId}&app_id={appId}&limit={limit}&offset={offset}&extra=albums";
var response = await GetWithAuthAsync(url, cancellationToken);
using var response = await GetWithAuthAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) break;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
if (!result.RootElement.TryGetProperty("albums", out var albumsData) ||
!albumsData.TryGetProperty("items", out var items))
@@ -362,11 +365,11 @@ public class QobuzMetadataService : TrackParserBase, IConcreteMetadataService
var appId = await _bundleService.GetAppIdAsync();
var url = $"{BaseUrl}playlist/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
var response = await GetWithAuthAsync(url, cancellationToken);
using var response = await GetWithAuthAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var playlists = new List<ExternalPlaylist>();
if (result.RootElement.TryGetProperty("playlists", out var playlistsData) &&
@@ -396,11 +399,12 @@ public class QobuzMetadataService : TrackParserBase, IConcreteMetadataService
var appId = await _bundleService.GetAppIdAsync();
var url = $"{BaseUrl}playlist/get?playlist_id={externalId}&app_id={appId}";
var response = await GetWithAuthAsync(url, cancellationToken);
using var response = await GetWithAuthAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var playlistElement = JsonDocument.Parse(json).RootElement;
using var playlistDocument = JsonDocument.Parse(json);
var playlistElement = playlistDocument.RootElement;
if (playlistElement.TryGetProperty("error", out _)) return null;
@@ -422,11 +426,12 @@ public class QobuzMetadataService : TrackParserBase, IConcreteMetadataService
var appId = await _bundleService.GetAppIdAsync();
var url = $"{BaseUrl}playlist/get?playlist_id={externalId}&app_id={appId}&extra=tracks";
var response = await GetWithAuthAsync(url, cancellationToken);
using var response = await GetWithAuthAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Song>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var playlistElement = JsonDocument.Parse(json).RootElement;
using var playlistDocument = JsonDocument.Parse(json);
var playlistElement = playlistDocument.RootElement;
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
@@ -292,7 +292,7 @@ public class LastFmScrobblingService : IScrobblingService
var content = new FormUrlEncodedContent(parameters);
// Send request
var response = await _httpClient.PostAsync(ApiRoot, content, cancellationToken);
using var response = await _httpClient.PostAsync(ApiRoot, content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
// Log request/response for debugging
@@ -259,7 +259,7 @@ public class ListenBrainzScrobblingService : IScrobblingService
request.Headers.Add("Authorization", $"Token {_settings.UserToken}");
var response = await _httpClient.SendAsync(request, cancellationToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogTrace("ListenBrainz request: {Endpoint}, Response: {StatusCode}",
@@ -273,7 +273,7 @@ public class ListenBrainzScrobblingService : IScrobblingService
// Parse error response
try
{
var errorDoc = JsonDocument.Parse(responseBody);
using var errorDoc = JsonDocument.Parse(responseBody);
var errorMessage = errorDoc.RootElement.GetProperty("error").GetString() ?? "Unknown error";
var errorCode = (int)response.StatusCode;
@@ -91,7 +91,7 @@ public class SquidWTFDownloadService : BaseDownloadService
{
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl);
using var response = await _httpClient.GetAsync(baseUrl);
return response.IsSuccessStatusCode;
});
}
@@ -149,7 +149,7 @@ public class SquidWTFDownloadService : BaseDownloadService
using var req = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
req.Headers.Add("User-Agent", "Mozilla/5.0");
req.Headers.Add("Accept", "*/*");
var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
using var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken);
@@ -203,7 +203,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
{
// Use 's' parameter for track search as per hifi-api spec
var url = BuildSearchUrl(baseUrl, "s", query, normalizedLimit);
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -213,7 +213,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
var json = await response.Content.ReadAsStringAsync(cancellationToken);
// Check for error in response body
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
{
@@ -255,7 +255,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
// Use 'al' parameter for album search
// a= is for artists, al= is for albums, p= is for playlists
var url = BuildSearchUrl(baseUrl, "al", query, normalizedLimit);
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -263,7 +263,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var albums = new List<Album>();
// Per hifi-api spec: album search returns data.albums.items array
@@ -299,7 +299,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
var url = BuildSearchUrl(baseUrl, "a", query, normalizedLimit);
_logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -308,7 +308,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
@@ -450,14 +450,14 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
{
// Per hifi-api spec: use 'p' parameter for playlist search
var url = BuildSearchUrl(baseUrl, "p", query, normalizedLimit);
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
@@ -533,7 +533,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
async (baseUrl) =>
{
var url = BuildSearchUrl(baseUrl, "i", normalizedIsrc, IsrcLookupLimit);
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -541,7 +541,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
@@ -670,7 +670,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
url += $"&limit={limit}";
}
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -679,7 +679,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
if (!result.RootElement.TryGetProperty("data", out var data) ||
!data.TryGetProperty("items", out var items) ||
@@ -771,7 +771,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
while (true)
{
var url = BuildPagedEndpointUrl(baseUrl, "album", "id", externalId, MetadataPageSize, offset);
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
@@ -851,7 +851,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
var url = $"{baseUrl}/artist/?id={Uri.EscapeDataString(externalId)}";
_logger.LogDebug("Fetching artist from {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
@@ -908,7 +908,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
// Per hifi-api README: /artist/?f={artistId} returns aggregated releases and tracks
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogDebug("Fetching artist albums from URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -917,7 +917,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
var json = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogDebug("SquidWTF artist albums response for {ExternalId}: {JsonLength} bytes", externalId, json.Length);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var albums = new List<Album>();
@@ -955,7 +955,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
// Per hifi-api README: /artist/?f={artistId} returns both albums and tracks
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogDebug("Fetching artist tracks from URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -964,7 +964,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
var json = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogDebug("SquidWTF artist tracks response for {ExternalId}: {JsonLength} bytes", externalId, json.Length);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
var tracks = new List<Song>();
@@ -995,7 +995,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = BuildPagedEndpointUrl(baseUrl, "playlist", "id", externalId, RemoteSearchMinLimit);
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
@@ -1038,7 +1038,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
while (true)
{
var url = BuildPagedEndpointUrl(baseUrl, "playlist", "id", externalId, MetadataPageSize, offset);
var response = await _httpClient.GetAsync(url, cancellationToken);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
@@ -1624,7 +1624,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
try
{
var url = BuildSearchUrl(baseUrl, "s", query, normalizedLimit);
var response = await _httpClient.GetAsync(url, ct);
using var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
@@ -1632,7 +1632,7 @@ public class SquidWTFMetadataService : TrackParserBase, IConcreteMetadataService
}
var json = await response.Content.ReadAsStringAsync(ct);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
@@ -47,7 +47,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
var apiResult = await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
using var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
if (response.IsSuccessStatusCode)
{
@@ -68,7 +68,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
{
var streamingResult = await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
using var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
if (response.IsSuccessStatusCode)
{
@@ -108,7 +108,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
using var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
return response.IsSuccessStatusCode;
}
catch
@@ -39,7 +39,7 @@ public class SubsonicModelMapper
if (contentType?.Contains("json") == true)
{
var jsonDoc = JsonDocument.Parse(content);
using var jsonDoc = JsonDocument.Parse(content);
if (jsonDoc.RootElement.TryGetProperty("subsonic-response", out var response) &&
response.TryGetProperty("searchResult3", out var searchResult))
{
@@ -55,12 +55,6 @@ public class SubsonicProxyService
response.StatusCode);
}
// Trigger GC for large files to prevent memory leaks
if (response.Body.Length > 1024 * 1024) // 1MB threshold
{
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
}
return (response.Body, response.ContentType);
}
@@ -30,7 +30,7 @@ public class SubsonicStartupValidator : BaseStartupValidator
try
{
var pingUrl = $"{subsonicUrl.TrimEnd('/')}/rest/ping.view?v=1.16.1&c=allstarr&f=json";
var response = await _httpClient.GetAsync(pingUrl, cancellationToken);
using var response = await _httpClient.GetAsync(pingUrl, cancellationToken);
if (response.IsSuccessStatusCode)
{
+12 -5
View File
@@ -37,10 +37,11 @@ The repository gateway is a narrow HTTP adapter around the official upstream pro
[wrapper-v2](https://github.com/glomatico/wrapper-v2). It exposes catalog song lookup, download-backed streaming,
managed song downloads, health, login, and 2FA. It runs on the private Compose network and publishes no host port.
The gateway advertises only routes that Allstarr has implemented and tested. GAMDL can do more upstream, but album,
playlist, artist, library, video, lyrics-artifact, and artwork lanes stay unavailable in Allstarr until their
managed-artifact contracts exist. That keeps the UI honest and prevents an upstream feature name from becoming a
false promise.
The gateway advertises only routes that Allstarr has implemented and tested: song, album, and artist metadata;
artist discography; track streaming and managed downloads; and synced lyrics artifacts. GAMDL can do more upstream,
but album downloads, playlist or library mutation, video, and standalone artwork-artifact lanes stay unavailable
until their managed-artifact contracts exist. That keeps the UI honest and prevents an upstream feature name from
becoming a false promise.
Before connecting it, confirm that:
@@ -59,9 +60,14 @@ Allstarr first requests `GET /api/capabilities`. A compatible version 1 response
"sidecarApiVersion": "1.0.0",
"capabilities": [
{ "id": "metadata-search-song", "state": "supported" },
{ "id": "metadata-search-album", "state": "supported" },
{ "id": "metadata-search-artist", "state": "supported" },
{ "id": "metadata-song", "state": "supported" },
{ "id": "metadata-album", "state": "supported" },
{ "id": "metadata-artist", "state": "supported" },
{ "id": "stream-audio-song", "state": "supported" },
{ "id": "download-audio-song", "state": "supported" }
{ "id": "download-audio-song", "state": "supported" },
{ "id": "synced-lyrics-artifact", "state": "supported" }
]
}
```
@@ -82,6 +88,7 @@ The current typed lanes use these routes:
| Artist albums and tracks | `GET /api/artist/{id}/albums`, `GET /api/artist/{id}/tracks` |
| Managed track artifact | `GET /api/download/{id}?quality={quality}` |
| Progressive playback | `GET /api/stream/{id}?quality={quality}` |
| Synced lyrics artifact | `GET /api/lyrics/{id}` |
Health must report the gateway, GAMDL runtime, and wrapper dependency truthfully. A raw wrapper-v2 `/health`
response is not enough. Allstarr refuses redirects during discovery and login so credentials cannot be forwarded to
+3 -2
View File
@@ -57,8 +57,9 @@ SAMPLES=5 bash tools/tests/live_jellyfin_smoke.sh
Do not run the live layer while either endpoint is unhealthy. The default live
suite does not write playlists, favorites, played state, ratings, display
preferences, or lyrics. It also avoids provider-backed audio downloads. Every
ranged request has a hard 65,536-byte curl ceiling. A provider that ignores
`Range: bytes=0-65535` fails the check instead of downloading the whole track.
ranged request has a hard 65,536-byte curl ceiling. Provider streams exercise
both prefix and suffix byte ranges; a provider that ignores the range fails
the check instead of downloading the whole track.
Add `TEST_EXTERNAL_STREAM=1` only when a bounded cold/cache provider stream is
intended:
+7 -4
View File
@@ -374,11 +374,11 @@ check_image() {
}
check_external_stream() {
local label="$1" url="$2" result code content_type bytes ttfb total reader_pid
local label="$1" url="$2" range="${3:-0-65535}" result code content_type bytes ttfb total reader_pid
: >"$response_file"
head -c 65536 <"$stream_pipe" >"$response_file" &
reader_pid=$!
result="$(curl -sS --max-time "$TIMEOUT_SECONDS" "${auth[@]}" --range 0-65535 \
result="$(curl -sS --max-time "$TIMEOUT_SECONDS" "${auth[@]}" --range "$range" \
-o "$stream_pipe" \
-w '%{http_code}\t%{content_type}\t%{size_download}\t%{time_starttransfer}\t%{time_total}' \
"$url" || true)"
@@ -389,8 +389,8 @@ check_external_stream() {
checks=$((checks + 1))
local saved_bytes
saved_bytes="$(wc -c <"$response_file" | tr -d ' ')"
if [[ ",$code," == *,200,* || ",$code," == *,206,* ]] &&
(( saved_bytes > 0 && saved_bytes <= 65536 )) &&
if [[ "$code" == 206 ]] &&
(( saved_bytes > 0 && saved_bytes <= 65536 && bytes == saved_bytes )) &&
awk -v value="${ttfb:-0}" -v max="$MAX_EXTERNAL_STREAM_TTFB_MS" \
'BEGIN { exit !((value * 1000) <= max) }'; then
printf 'PASS %-34s status=%s bytes=%s ttfb_ms=%.1f total_ms=%.1f\n' \
@@ -1111,6 +1111,9 @@ if [[ -n "$external_song_id" ]]; then
if [[ "$TEST_EXTERNAL_STREAM" == 1 ]]; then
check_external_stream "external stream-64k" \
"$ALLSTARR_BASE/Audio/$external_song_id/stream?static=true&UserId=$best_user_id"
check_external_stream "external suffix stream-64k" \
"$ALLSTARR_BASE/Audio/$external_song_id/stream?static=true&UserId=$best_user_id" \
-65536
else
echo "external stream skipped=set TEST_EXTERNAL_STREAM=1 for provider/cold-cache media"
fi