mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
fix: harden provider streaming and session lifecycle
This commit is contained in:
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user