refactor: extract subsonic controller logic into specialized services

- Extract SubsonicRequestParser for HTTP parameter extraction
- Extract SubsonicResponseBuilder for XML/JSON response formatting
- Extract SubsonicModelMapper for search result parsing and merging
- Extract SubsonicProxyService for upstream Subsonic server communication
- Add comprehensive test coverage (45 tests) for all new services
- Reduce SubsonicController from 1174 to 666 lines (-43%)

All tests passing. Build succeeds with 0 errors.
This commit is contained in:
V1ck3s
2026-01-08 21:47:05 +01:00
parent 09ee618ac8
commit 9245dac99e
10 changed files with 2031 additions and 574 deletions

View File

@@ -0,0 +1,347 @@
using Microsoft.Extensions.Logging;
using Moq;
using octo_fiesta.Models.Domain;
using octo_fiesta.Models.Search;
using octo_fiesta.Services.Subsonic;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
namespace octo_fiesta.Tests;
public class SubsonicModelMapperTests
{
private readonly SubsonicModelMapper _mapper;
private readonly Mock<ILogger<SubsonicModelMapper>> _mockLogger;
private readonly SubsonicResponseBuilder _responseBuilder;
public SubsonicModelMapperTests()
{
_responseBuilder = new SubsonicResponseBuilder();
_mockLogger = new Mock<ILogger<SubsonicModelMapper>>();
_mapper = new SubsonicModelMapper(_responseBuilder, _mockLogger.Object);
}
[Fact]
public void ParseSearchResponse_JsonWithSongs_ParsesCorrectly()
{
// Arrange
var jsonResponse = @"{
""subsonic-response"": {
""status"": ""ok"",
""version"": ""1.16.1"",
""searchResult3"": {
""song"": [
{
""id"": ""song1"",
""title"": ""Test Song"",
""artist"": ""Test Artist"",
""album"": ""Test Album""
}
]
}
}
}";
var responseBody = Encoding.UTF8.GetBytes(jsonResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Single(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void ParseSearchResponse_XmlWithSongs_ParsesCorrectly()
{
// Arrange
var xmlResponse = @"<?xml version=""1.0"" encoding=""UTF-8""?>
<subsonic-response xmlns=""http://subsonic.org/restapi"" status=""ok"" version=""1.16.1"">
<searchResult3>
<song id=""song1"" title=""Test Song"" artist=""Test Artist"" album=""Test Album"" />
</searchResult3>
</subsonic-response>";
var responseBody = Encoding.UTF8.GetBytes(xmlResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/xml");
// Assert
Assert.Single(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void ParseSearchResponse_JsonWithAllTypes_ParsesAllCorrectly()
{
// Arrange
var jsonResponse = @"{
""subsonic-response"": {
""status"": ""ok"",
""version"": ""1.16.1"",
""searchResult3"": {
""song"": [
{""id"": ""song1"", ""title"": ""Song 1""}
],
""album"": [
{""id"": ""album1"", ""name"": ""Album 1""}
],
""artist"": [
{""id"": ""artist1"", ""name"": ""Artist 1""}
]
}
}
}";
var responseBody = Encoding.UTF8.GetBytes(jsonResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Single(songs);
Assert.Single(albums);
Assert.Single(artists);
}
[Fact]
public void ParseSearchResponse_XmlWithAllTypes_ParsesAllCorrectly()
{
// Arrange
var xmlResponse = @"<?xml version=""1.0"" encoding=""UTF-8""?>
<subsonic-response xmlns=""http://subsonic.org/restapi"" status=""ok"" version=""1.16.1"">
<searchResult3>
<song id=""song1"" title=""Song 1"" />
<album id=""album1"" name=""Album 1"" />
<artist id=""artist1"" name=""Artist 1"" />
</searchResult3>
</subsonic-response>";
var responseBody = Encoding.UTF8.GetBytes(xmlResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/xml");
// Assert
Assert.Single(songs);
Assert.Single(albums);
Assert.Single(artists);
}
[Fact]
public void ParseSearchResponse_InvalidJson_ReturnsEmpty()
{
// Arrange
var invalidJson = "{invalid json}";
var responseBody = Encoding.UTF8.GetBytes(invalidJson);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Empty(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void ParseSearchResponse_EmptySearchResult_ReturnsEmpty()
{
// Arrange
var jsonResponse = @"{
""subsonic-response"": {
""status"": ""ok"",
""version"": ""1.16.1"",
""searchResult3"": {}
}
}";
var responseBody = Encoding.UTF8.GetBytes(jsonResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Empty(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void MergeSearchResults_Json_MergesSongsCorrectly()
{
// Arrange
var localSongs = new List<object>
{
new Dictionary<string, object> { ["id"] = "local1", ["title"] = "Local Song" }
};
var externalResult = new SearchResult
{
Songs = new List<Song>
{
new Song { Id = "ext1", Title = "External Song" }
},
Albums = new List<Album>(),
Artists = new List<Artist>()
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
localSongs, new List<object>(), new List<object>(), externalResult, true);
// Assert
Assert.Equal(2, mergedSongs.Count);
}
[Fact]
public void MergeSearchResults_Json_DeduplicatesArtists()
{
// Arrange
var localArtists = new List<object>
{
new Dictionary<string, object> { ["id"] = "local1", ["name"] = "Test Artist" }
};
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>
{
new Artist { Id = "ext1", Name = "Test Artist" }, // Same name - should be filtered
new Artist { Id = "ext2", Name = "Different Artist" } // Different name - should be included
}
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), localArtists, externalResult, true);
// Assert
Assert.Equal(2, mergedArtists.Count); // 1 local + 1 external (duplicate filtered)
}
[Fact]
public void MergeSearchResults_Json_CaseInsensitiveDeduplication()
{
// Arrange
var localArtists = new List<object>
{
new Dictionary<string, object> { ["id"] = "local1", ["name"] = "Test Artist" }
};
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>
{
new Artist { Id = "ext1", Name = "test artist" } // Different case - should still be filtered
}
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), localArtists, externalResult, true);
// Assert
Assert.Single(mergedArtists); // Only the local artist
}
[Fact]
public void MergeSearchResults_Xml_MergesSongsCorrectly()
{
// Arrange
var ns = XNamespace.Get("http://subsonic.org/restapi");
var localSongs = new List<object>
{
new XElement("song", new XAttribute("id", "local1"), new XAttribute("title", "Local Song"))
};
var externalResult = new SearchResult
{
Songs = new List<Song>
{
new Song { Id = "ext1", Title = "External Song" }
},
Albums = new List<Album>(),
Artists = new List<Artist>()
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
localSongs, new List<object>(), new List<object>(), externalResult, false);
// Assert
Assert.Equal(2, mergedSongs.Count);
}
[Fact]
public void MergeSearchResults_Xml_DeduplicatesArtists()
{
// Arrange
var localArtists = new List<object>
{
new XElement("artist", new XAttribute("id", "local1"), new XAttribute("name", "Test Artist"))
};
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>
{
new Artist { Id = "ext1", Name = "Test Artist" }, // Same name - should be filtered
new Artist { Id = "ext2", Name = "Different Artist" } // Different name - should be included
}
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), localArtists, externalResult, false);
// Assert
Assert.Equal(2, mergedArtists.Count); // 1 local + 1 external (duplicate filtered)
}
[Fact]
public void MergeSearchResults_EmptyLocalResults_ReturnsOnlyExternal()
{
// Arrange
var externalResult = new SearchResult
{
Songs = new List<Song> { new Song { Id = "ext1" } },
Albums = new List<Album> { new Album { Id = "ext2" } },
Artists = new List<Artist> { new Artist { Id = "ext3", Name = "Artist" } }
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), new List<object>(), externalResult, true);
// Assert
Assert.Single(mergedSongs);
Assert.Single(mergedAlbums);
Assert.Single(mergedArtists);
}
[Fact]
public void MergeSearchResults_EmptyExternalResults_ReturnsOnlyLocal()
{
// Arrange
var localSongs = new List<object> { new Dictionary<string, object> { ["id"] = "local1" } };
var localAlbums = new List<object> { new Dictionary<string, object> { ["id"] = "local2" } };
var localArtists = new List<object> { new Dictionary<string, object> { ["id"] = "local3", ["name"] = "Local" } };
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>()
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
localSongs, localAlbums, localArtists, externalResult, true);
// Assert
Assert.Single(mergedSongs);
Assert.Single(mergedAlbums);
Assert.Single(mergedArtists);
}
}

View File

@@ -0,0 +1,325 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using octo_fiesta.Models.Settings;
using octo_fiesta.Services.Subsonic;
using System.Net;
namespace octo_fiesta.Tests;
public class SubsonicProxyServiceTests
{
private readonly SubsonicProxyService _service;
private readonly Mock<HttpMessageHandler> _mockHttpMessageHandler;
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
public SubsonicProxyServiceTests()
{
_mockHttpMessageHandler = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_mockHttpMessageHandler.Object);
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
_mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
var settings = Options.Create(new SubsonicSettings
{
Url = "http://localhost:4533"
});
_service = new SubsonicProxyService(_mockHttpClientFactory.Object, settings);
}
[Fact]
public async Task RelayAsync_SuccessfulRequest_ReturnsBodyAndContentType()
{
// Arrange
var responseContent = new byte[] { 1, 2, 3, 4, 5 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(responseContent)
};
responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "u", "admin" },
{ "p", "password" },
{ "v", "1.16.0" }
};
// Act
var (body, contentType) = await _service.RelayAsync("rest/ping", parameters);
// Assert
Assert.Equal(responseContent, body);
Assert.Equal("application/json", contentType);
}
[Fact]
public async Task RelayAsync_BuildsCorrectUrl()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(Array.Empty<byte>())
};
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "u", "admin" },
{ "p", "secret" }
};
// Act
await _service.RelayAsync("rest/ping", parameters);
// Assert
Assert.NotNull(capturedRequest);
Assert.Contains("http://localhost:4533/rest/ping", capturedRequest!.RequestUri!.ToString());
Assert.Contains("u=admin", capturedRequest.RequestUri.ToString());
Assert.Contains("p=secret", capturedRequest.RequestUri.ToString());
}
[Fact]
public async Task RelayAsync_EncodesSpecialCharacters()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(Array.Empty<byte>())
};
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "query", "rock & roll" },
{ "artist", "AC/DC" }
};
// Act
await _service.RelayAsync("rest/search3", parameters);
// Assert
Assert.NotNull(capturedRequest);
var url = capturedRequest!.RequestUri!.ToString();
// HttpClient automatically applies URL encoding when building the URI
// Space can be encoded as + or %20, & as %26, / as %2F
Assert.Contains("query=", url);
Assert.Contains("artist=", url);
Assert.Contains("AC%2FDC", url); // / should be encoded as %2F
}
[Fact]
public async Task RelayAsync_HttpError_ThrowsException()
{
// Arrange
var responseMessage = new HttpResponseMessage(HttpStatusCode.NotFound);
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act & Assert
await Assert.ThrowsAsync<HttpRequestException>(() =>
_service.RelayAsync("rest/ping", parameters));
}
[Fact]
public async Task RelaySafeAsync_SuccessfulRequest_ReturnsSuccessTrue()
{
// Arrange
var responseContent = new byte[] { 1, 2, 3 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(responseContent)
};
responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/xml");
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act
var (body, contentType, success) = await _service.RelaySafeAsync("rest/ping", parameters);
// Assert
Assert.True(success);
Assert.Equal(responseContent, body);
Assert.Equal("application/xml", contentType);
}
[Fact]
public async Task RelaySafeAsync_HttpError_ReturnsSuccessFalse()
{
// Arrange
var responseMessage = new HttpResponseMessage(HttpStatusCode.InternalServerError);
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act
var (body, contentType, success) = await _service.RelaySafeAsync("rest/ping", parameters);
// Assert
Assert.False(success);
Assert.Null(body);
Assert.Null(contentType);
}
[Fact]
public async Task RelaySafeAsync_NetworkException_ReturnsSuccessFalse()
{
// Arrange
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act
var (body, contentType, success) = await _service.RelaySafeAsync("rest/ping", parameters);
// Assert
Assert.False(success);
Assert.Null(body);
Assert.Null(contentType);
}
[Fact]
public async Task RelayStreamAsync_SuccessfulRequest_ReturnsFileStreamResult()
{
// Arrange
var streamContent = new byte[] { 1, 2, 3, 4, 5 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(streamContent)
};
responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("audio/mpeg");
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "id", "song123" },
{ "u", "admin" }
};
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var fileResult = Assert.IsType<FileStreamResult>(result);
Assert.Equal("audio/mpeg", fileResult.ContentType);
Assert.True(fileResult.EnableRangeProcessing);
}
[Fact]
public async Task RelayStreamAsync_HttpError_ReturnsStatusCodeResult()
{
// Arrange
var responseMessage = new HttpResponseMessage(HttpStatusCode.NotFound);
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var statusResult = Assert.IsType<StatusCodeResult>(result);
Assert.Equal(404, statusResult.StatusCode);
}
[Fact]
public async Task RelayStreamAsync_Exception_ReturnsObjectResultWith500()
{
// Arrange
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection failed"));
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(500, objectResult.StatusCode);
}
[Fact]
public async Task RelayStreamAsync_DefaultContentType_UsesAudioMpeg()
{
// Arrange
var streamContent = new byte[] { 1, 2, 3 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(streamContent)
// No ContentType set
};
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var fileResult = Assert.IsType<FileStreamResult>(result);
Assert.Equal("audio/mpeg", fileResult.ContentType);
}
}

View File

@@ -0,0 +1,202 @@
using Microsoft.AspNetCore.Http;
using octo_fiesta.Services.Subsonic;
using System.Text;
namespace octo_fiesta.Tests;
public class SubsonicRequestParserTests
{
private readonly SubsonicRequestParser _parser;
public SubsonicRequestParserTests()
{
_parser = new SubsonicRequestParser();
}
[Fact]
public async Task ExtractAllParametersAsync_QueryParameters_ExtractsCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?u=admin&p=password&v=1.16.0&c=testclient&f=json");
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(5, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("1.16.0", result["v"]);
Assert.Equal("testclient", result["c"]);
Assert.Equal("json", result["f"]);
}
[Fact]
public async Task ExtractAllParametersAsync_FormEncodedBody_ExtractsCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
var formData = "u=admin&p=password&query=test+artist&artistCount=10";
var bytes = Encoding.UTF8.GetBytes(formData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/x-www-form-urlencoded";
context.Request.ContentLength = bytes.Length;
context.Request.Method = "POST";
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(4, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("test artist", result["query"]);
Assert.Equal("10", result["artistCount"]);
}
[Fact]
public async Task ExtractAllParametersAsync_JsonBody_ExtractsCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
var jsonData = "{\"u\":\"admin\",\"p\":\"password\",\"query\":\"test artist\",\"artistCount\":10}";
var bytes = Encoding.UTF8.GetBytes(jsonData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(4, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("test artist", result["query"]);
Assert.Equal("10", result["artistCount"]);
}
[Fact]
public async Task ExtractAllParametersAsync_QueryAndFormBody_MergesCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?u=admin&p=password&f=json");
var formData = "query=test&artistCount=5";
var bytes = Encoding.UTF8.GetBytes(formData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/x-www-form-urlencoded";
context.Request.ContentLength = bytes.Length;
context.Request.Method = "POST";
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(5, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("json", result["f"]);
Assert.Equal("test", result["query"]);
Assert.Equal("5", result["artistCount"]);
}
[Fact]
public async Task ExtractAllParametersAsync_EmptyRequest_ReturnsEmptyDictionary()
{
// Arrange
var context = new DefaultHttpContext();
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Empty(result);
}
[Fact]
public async Task ExtractAllParametersAsync_SpecialCharacters_EncodesCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?query=rock+%26+roll&artist=AC%2FDC");
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("rock & roll", result["query"]);
Assert.Equal("AC/DC", result["artist"]);
}
[Fact]
public async Task ExtractAllParametersAsync_InvalidJson_IgnoresBody()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?u=admin");
var invalidJson = "{invalid json}";
var bytes = Encoding.UTF8.GetBytes(invalidJson);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Single(result);
Assert.Equal("admin", result["u"]);
}
[Fact]
public async Task ExtractAllParametersAsync_NullJsonValues_HandlesGracefully()
{
// Arrange
var context = new DefaultHttpContext();
var jsonData = "{\"u\":\"admin\",\"p\":null,\"query\":\"test\"}";
var bytes = Encoding.UTF8.GetBytes(jsonData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(3, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("", result["p"]);
Assert.Equal("test", result["query"]);
}
[Fact]
public async Task ExtractAllParametersAsync_DuplicateKeys_BodyOverridesQuery()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?format=xml&query=old");
var jsonData = "{\"query\":\"new\",\"artist\":\"Beatles\"}";
var bytes = Encoding.UTF8.GetBytes(jsonData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(3, result.Count);
Assert.Equal("xml", result["format"]);
Assert.Equal("new", result["query"]); // Body overrides query
Assert.Equal("Beatles", result["artist"]);
}
}

View File

@@ -0,0 +1,322 @@
using Microsoft.AspNetCore.Mvc;
using octo_fiesta.Models.Domain;
using octo_fiesta.Services.Subsonic;
using System.Text.Json;
using System.Xml.Linq;
namespace octo_fiesta.Tests;
public class SubsonicResponseBuilderTests
{
private readonly SubsonicResponseBuilder _builder;
public SubsonicResponseBuilderTests()
{
_builder = new SubsonicResponseBuilder();
}
[Fact]
public void CreateResponse_JsonFormat_ReturnsJsonWithOkStatus()
{
// Act
var result = _builder.CreateResponse("json", "testElement", new { });
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
Assert.NotNull(jsonResult.Value);
// Serialize and deserialize to check structure
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
Assert.Equal("ok", doc.RootElement.GetProperty("subsonic-response").GetProperty("status").GetString());
Assert.Equal("1.16.1", doc.RootElement.GetProperty("subsonic-response").GetProperty("version").GetString());
}
[Fact]
public void CreateResponse_XmlFormat_ReturnsXmlWithOkStatus()
{
// Act
var result = _builder.CreateResponse("xml", "testElement", new { });
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var root = doc.Root!;
Assert.Equal("subsonic-response", root.Name.LocalName);
Assert.Equal("ok", root.Attribute("status")?.Value);
Assert.Equal("1.16.1", root.Attribute("version")?.Value);
}
[Fact]
public void CreateError_JsonFormat_ReturnsJsonWithError()
{
// Act
var result = _builder.CreateError("json", 70, "Test error message");
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var response = doc.RootElement.GetProperty("subsonic-response");
Assert.Equal("failed", response.GetProperty("status").GetString());
Assert.Equal(70, response.GetProperty("error").GetProperty("code").GetInt32());
Assert.Equal("Test error message", response.GetProperty("error").GetProperty("message").GetString());
}
[Fact]
public void CreateError_XmlFormat_ReturnsXmlWithError()
{
// Act
var result = _builder.CreateError("xml", 70, "Test error message");
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var root = doc.Root!;
Assert.Equal("failed", root.Attribute("status")?.Value);
var ns = root.GetDefaultNamespace();
var errorElement = root.Element(ns + "error");
Assert.NotNull(errorElement);
Assert.Equal("70", errorElement.Attribute("code")?.Value);
Assert.Equal("Test error message", errorElement.Attribute("message")?.Value);
}
[Fact]
public void CreateSongResponse_JsonFormat_ReturnsSongData()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song",
Artist = "Test Artist",
Album = "Test Album",
Duration = 180,
Track = 5,
Year = 2023,
Genre = "Rock",
LocalPath = "/music/test.mp3"
};
// Act
var result = _builder.CreateSongResponse("json", song);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var songData = doc.RootElement.GetProperty("subsonic-response").GetProperty("song");
Assert.Equal("song123", songData.GetProperty("id").GetString());
Assert.Equal("Test Song", songData.GetProperty("title").GetString());
Assert.Equal("Test Artist", songData.GetProperty("artist").GetString());
Assert.Equal("Test Album", songData.GetProperty("album").GetString());
}
[Fact]
public void CreateSongResponse_XmlFormat_ReturnsSongData()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song",
Artist = "Test Artist",
Album = "Test Album",
Duration = 180
};
// Act
var result = _builder.CreateSongResponse("xml", song);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var songElement = doc.Root!.Element(ns + "song");
Assert.NotNull(songElement);
Assert.Equal("song123", songElement.Attribute("id")?.Value);
Assert.Equal("Test Song", songElement.Attribute("title")?.Value);
}
[Fact]
public void CreateAlbumResponse_JsonFormat_ReturnsAlbumWithSongs()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Test Album",
Artist = "Test Artist",
Year = 2023,
Songs = new List<Song>
{
new Song { Id = "song1", Title = "Song 1", Duration = 180 },
new Song { Id = "song2", Title = "Song 2", Duration = 200 }
}
};
// Act
var result = _builder.CreateAlbumResponse("json", album);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var albumData = doc.RootElement.GetProperty("subsonic-response").GetProperty("album");
Assert.Equal("album123", albumData.GetProperty("id").GetString());
Assert.Equal("Test Album", albumData.GetProperty("name").GetString());
Assert.Equal(2, albumData.GetProperty("songCount").GetInt32());
Assert.Equal(380, albumData.GetProperty("duration").GetInt32());
}
[Fact]
public void CreateAlbumResponse_XmlFormat_ReturnsAlbumWithSongs()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Test Album",
Artist = "Test Artist",
SongCount = 2,
Songs = new List<Song>
{
new Song { Id = "song1", Title = "Song 1" },
new Song { Id = "song2", Title = "Song 2" }
}
};
// Act
var result = _builder.CreateAlbumResponse("xml", album);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var albumElement = doc.Root!.Element(ns + "album");
Assert.NotNull(albumElement);
Assert.Equal("album123", albumElement.Attribute("id")?.Value);
Assert.Equal("2", albumElement.Attribute("songCount")?.Value);
}
[Fact]
public void CreateArtistResponse_JsonFormat_ReturnsArtistData()
{
// Arrange
var artist = new Artist
{
Id = "artist123",
Name = "Test Artist"
};
var albums = new List<Album>
{
new Album { Id = "album1", Title = "Album 1" },
new Album { Id = "album2", Title = "Album 2" }
};
// Act
var result = _builder.CreateArtistResponse("json", artist, albums);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var artistData = doc.RootElement.GetProperty("subsonic-response").GetProperty("artist");
Assert.Equal("artist123", artistData.GetProperty("id").GetString());
Assert.Equal("Test Artist", artistData.GetProperty("name").GetString());
Assert.Equal(2, artistData.GetProperty("albumCount").GetInt32());
}
[Fact]
public void CreateArtistResponse_XmlFormat_ReturnsArtistData()
{
// Arrange
var artist = new Artist
{
Id = "artist123",
Name = "Test Artist"
};
var albums = new List<Album>
{
new Album { Id = "album1", Title = "Album 1" },
new Album { Id = "album2", Title = "Album 2" }
};
// Act
var result = _builder.CreateArtistResponse("xml", artist, albums);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var artistElement = doc.Root!.Element(ns + "artist");
Assert.NotNull(artistElement);
Assert.Equal("artist123", artistElement.Attribute("id")?.Value);
Assert.Equal("Test Artist", artistElement.Attribute("name")?.Value);
Assert.Equal("2", artistElement.Attribute("albumCount")?.Value);
}
[Fact]
public void CreateSongResponse_SongWithNullValues_HandlesGracefully()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song"
// Other fields are null
};
// Act
var result = _builder.CreateSongResponse("json", song);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var songData = doc.RootElement.GetProperty("subsonic-response").GetProperty("song");
Assert.Equal("song123", songData.GetProperty("id").GetString());
Assert.Equal("Test Song", songData.GetProperty("title").GetString());
}
[Fact]
public void CreateAlbumResponse_EmptySongList_ReturnsZeroCounts()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Empty Album",
Artist = "Test Artist",
Songs = new List<Song>()
};
// Act
var result = _builder.CreateAlbumResponse("json", album);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var albumData = doc.RootElement.GetProperty("subsonic-response").GetProperty("album");
Assert.Equal(0, albumData.GetProperty("songCount").GetInt32());
Assert.Equal(0, albumData.GetProperty("duration").GetInt32());
}
}

View File

@@ -10,6 +10,7 @@ using octo_fiesta.Models.Search;
using octo_fiesta.Models.Subsonic;
using octo_fiesta.Services;
using octo_fiesta.Services.Local;
using octo_fiesta.Services.Subsonic;
namespace octo_fiesta.Controllers;
@@ -17,26 +18,35 @@ namespace octo_fiesta.Controllers;
[Route("")]
public class SubsonicController : ControllerBase
{
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _subsonicSettings;
private readonly IMusicMetadataService _metadataService;
private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService;
private readonly SubsonicRequestParser _requestParser;
private readonly SubsonicResponseBuilder _responseBuilder;
private readonly SubsonicModelMapper _modelMapper;
private readonly SubsonicProxyService _proxyService;
private readonly ILogger<SubsonicController> _logger;
public SubsonicController(
IHttpClientFactory httpClientFactory,
IOptions<SubsonicSettings> subsonicSettings,
IMusicMetadataService metadataService,
ILocalLibraryService localLibraryService,
IDownloadService downloadService,
SubsonicRequestParser requestParser,
SubsonicResponseBuilder responseBuilder,
SubsonicModelMapper modelMapper,
SubsonicProxyService proxyService,
ILogger<SubsonicController> logger)
{
_httpClient = httpClientFactory.CreateClient();
_subsonicSettings = subsonicSettings.Value;
_metadataService = metadataService;
_localLibraryService = localLibraryService;
_downloadService = downloadService;
_requestParser = requestParser;
_responseBuilder = responseBuilder;
_modelMapper = modelMapper;
_proxyService = proxyService;
_logger = logger;
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
@@ -44,91 +54,13 @@ public class SubsonicController : ControllerBase
throw new Exception("Error: Environment variable SUBSONIC_URL is not set.");
}
}
// Extract all parameters (query + body)
private async Task<Dictionary<string, string>> ExtractAllParameters()
{
var parameters = new Dictionary<string, string>();
// Get query parameters
foreach (var query in Request.Query)
{
parameters[query.Key] = query.Value.ToString();
}
// Get body parameters
if (Request.ContentLength > 0 || Request.ContentType != null)
{
// Handle application/x-www-form-urlencoded (OpenSubsonic formPost extension)
if (Request.HasFormContentType)
{
try
{
var form = await Request.ReadFormAsync();
foreach (var field in form)
{
parameters[field.Key] = field.Value.ToString();
}
}
catch
{
// Fall back to manual parsing if ReadFormAsync fails
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
Request.Body.Position = 0;
if (!string.IsNullOrEmpty(body))
{
var formParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(body);
foreach (var param in formParams)
{
parameters[param.Key] = param.Value.ToString();
}
}
}
}
// Handle application/json
else if (Request.ContentType?.Contains("application/json") == true)
{
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
if (!string.IsNullOrEmpty(body))
{
try
{
var bodyParams = JsonSerializer.Deserialize<Dictionary<string, object>>(body);
if (bodyParams != null)
{
foreach (var param in bodyParams)
{
parameters[param.Key] = param.Value?.ToString() ?? "";
}
}
}
catch (JsonException)
{
}
}
}
}
return parameters;
return await _requestParser.ExtractAllParametersAsync(Request);
}
private async Task<(object Body, string? ContentType)> RelayToSubsonic(string endpoint, Dictionary<string, string> parameters)
{
var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{_subsonicSettings.Url}/{endpoint}?{query}";
HttpResponseMessage response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString();
return (body, contentType);
}
/// <summary>
/// Merges local and external search results.
/// </summary>
@@ -147,17 +79,17 @@ public class SubsonicController : ControllerBase
{
try
{
var result = await RelayToSubsonic("rest/search3", parameters);
var result = await _proxyService.RelayAsync("rest/search3", parameters);
var contentType = result.ContentType ?? $"application/{format}";
return File((byte[])result.Body, contentType);
return File(result.Body, contentType);
}
catch
{
return CreateSubsonicResponse(format, "searchResult3", new { });
return _responseBuilder.CreateResponse(format, "searchResult3", new { });
}
}
var subsonicTask = RelayToSubsonicSafe("rest/search3", parameters);
var subsonicTask = _proxyService.RelaySafeAsync("rest/search3", parameters);
var externalTask = _metadataService.SearchAllAsync(
cleanQuery,
int.TryParse(parameters.GetValueOrDefault("songCount", "20"), out var sc) ? sc : 20,
@@ -193,7 +125,7 @@ public class SubsonicController : ControllerBase
if (!isExternal)
{
return await RelayStreamToSubsonic(parameters);
return await _proxyService.RelayStreamAsync(parameters, HttpContext.RequestAborted);
}
var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider!, externalId!);
@@ -229,26 +161,26 @@ public class SubsonicController : ControllerBase
if (string.IsNullOrWhiteSpace(id))
{
return CreateSubsonicError(format, 10, "Missing id parameter");
return _responseBuilder.CreateError(format, 10, "Missing id parameter");
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id);
if (!isExternal)
{
var result = await RelayToSubsonic("rest/getSong", parameters);
var result = await _proxyService.RelayAsync("rest/getSong", parameters);
var contentType = result.ContentType ?? $"application/{format}";
return File((byte[])result.Body, contentType);
return File(result.Body, contentType);
}
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song == null)
{
return CreateSubsonicError(format, 70, "Song not found");
return _responseBuilder.CreateError(format, 70, "Song not found");
}
return CreateSubsonicSongResponse(format, song);
return _responseBuilder.CreateSongResponse(format, song);
}
/// <summary>
@@ -265,7 +197,7 @@ public class SubsonicController : ControllerBase
if (string.IsNullOrWhiteSpace(id))
{
return CreateSubsonicError(format, 10, "Missing id parameter");
return _responseBuilder.CreateError(format, 10, "Missing id parameter");
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id);
@@ -275,7 +207,7 @@ public class SubsonicController : ControllerBase
var artist = await _metadataService.GetArtistAsync(provider!, externalId!);
if (artist == null)
{
return CreateSubsonicError(format, 70, "Artist not found");
return _responseBuilder.CreateError(format, 70, "Artist not found");
}
var albums = await _metadataService.GetArtistAlbumsAsync(provider!, externalId!);
@@ -293,14 +225,14 @@ public class SubsonicController : ControllerBase
}
}
return CreateSubsonicArtistResponse(format, artist, albums);
return _responseBuilder.CreateArtistResponse(format, artist, albums);
}
var navidromeResult = await RelayToSubsonicSafe("rest/getArtist", parameters);
var navidromeResult = await _proxyService.RelaySafeAsync("rest/getArtist", parameters);
if (!navidromeResult.Success || navidromeResult.Body == null)
{
return CreateSubsonicError(format, 70, "Artist not found");
return _responseBuilder.CreateError(format, 70, "Artist not found");
}
var navidromeContent = Encoding.UTF8.GetString(navidromeResult.Body);
@@ -316,13 +248,13 @@ public class SubsonicController : ControllerBase
response.TryGetProperty("artist", out var artistElement))
{
artistName = artistElement.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "";
artistData = ConvertSubsonicJsonElement(artistElement, true);
artistData = _responseBuilder.ConvertSubsonicJsonElement(artistElement, true);
if (artistElement.TryGetProperty("album", out var albums))
{
foreach (var album in albums.EnumerateArray())
{
localAlbums.Add(ConvertSubsonicJsonElement(album, true));
localAlbums.Add(_responseBuilder.ConvertSubsonicJsonElement(album, true));
}
}
}
@@ -373,7 +305,7 @@ public class SubsonicController : ControllerBase
{
if (!localAlbumNames.Contains(deezerAlbum.Title))
{
mergedAlbums.Add(ConvertAlbumToSubsonicJson(deezerAlbum));
mergedAlbums.Add(_responseBuilder.ConvertAlbumToJson(deezerAlbum));
}
}
@@ -383,7 +315,7 @@ public class SubsonicController : ControllerBase
artistDict["albumCount"] = mergedAlbums.Count;
}
return CreateSubsonicJsonResponse(new
return _responseBuilder.CreateJsonResponse(new
{
status = "ok",
version = "1.16.1",
@@ -405,7 +337,7 @@ public class SubsonicController : ControllerBase
if (string.IsNullOrWhiteSpace(id))
{
return CreateSubsonicError(format, 10, "Missing id parameter");
return _responseBuilder.CreateError(format, 10, "Missing id parameter");
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id);
@@ -416,17 +348,17 @@ public class SubsonicController : ControllerBase
if (album == null)
{
return CreateSubsonicError(format, 70, "Album not found");
return _responseBuilder.CreateError(format, 70, "Album not found");
}
return CreateSubsonicAlbumResponse(format, album);
return _responseBuilder.CreateAlbumResponse(format, album);
}
var navidromeResult = await RelayToSubsonicSafe("rest/getAlbum", parameters);
var navidromeResult = await _proxyService.RelaySafeAsync("rest/getAlbum", parameters);
if (!navidromeResult.Success || navidromeResult.Body == null)
{
return CreateSubsonicError(format, 70, "Album not found");
return _responseBuilder.CreateError(format, 70, "Album not found");
}
var navidromeContent = Encoding.UTF8.GetString(navidromeResult.Body);
@@ -443,13 +375,13 @@ public class SubsonicController : ControllerBase
{
albumName = albumElement.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "";
artistName = albumElement.TryGetProperty("artist", out var artist) ? artist.GetString() ?? "" : "";
albumData = ConvertSubsonicJsonElement(albumElement, true);
albumData = _responseBuilder.ConvertSubsonicJsonElement(albumElement, true);
if (albumElement.TryGetProperty("song", out var songs))
{
foreach (var song in songs.EnumerateArray())
{
localSongs.Add(ConvertSubsonicJsonElement(song, true));
localSongs.Add(_responseBuilder.ConvertSubsonicJsonElement(song, true));
}
}
}
@@ -508,7 +440,7 @@ public class SubsonicController : ControllerBase
{
if (!localSongTitles.Contains(deezerSong.Title))
{
mergedSongs.Add(ConvertSongToSubsonicJson(deezerSong));
mergedSongs.Add(_responseBuilder.ConvertSongToJson(deezerSong));
}
}
@@ -535,7 +467,7 @@ public class SubsonicController : ControllerBase
}
}
return CreateSubsonicJsonResponse(new
return _responseBuilder.CreateJsonResponse(new
{
status = "ok",
version = "1.16.1",
@@ -566,9 +498,9 @@ public class SubsonicController : ControllerBase
{
try
{
var result = await RelayToSubsonic("rest/getCoverArt", parameters);
var result = await _proxyService.RelayAsync("rest/getCoverArt", parameters);
var contentType = result.ContentType ?? "image/jpeg";
return File((byte[])result.Body, contentType);
return File(result.Body, contentType);
}
catch
{
@@ -619,7 +551,8 @@ public class SubsonicController : ControllerBase
if (coverUrl != null)
{
var response = await _httpClient.GetAsync(coverUrl);
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(coverUrl);
if (response.IsSuccessStatusCode)
{
var imageBytes = await response.Content.ReadAsByteArrayAsync();
@@ -633,148 +566,26 @@ public class SubsonicController : ControllerBase
#region Helper Methods
private async Task<(byte[]? Body, string? ContentType, bool Success)> RelayToSubsonicSafe(string endpoint, Dictionary<string, string> parameters)
{
try
{
var result = await RelayToSubsonic(endpoint, parameters);
return ((byte[])result.Body, result.ContentType, true);
}
catch
{
return (null, null, false);
}
}
private async Task<IActionResult> RelayStreamToSubsonic(Dictionary<string, string> parameters)
{
try
{
var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{_subsonicSettings.Url}/rest/stream?{query}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted);
if (!response.IsSuccessStatusCode)
{
return StatusCode((int)response.StatusCode);
}
var stream = await response.Content.ReadAsStreamAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
return File(stream, contentType, enableRangeProcessing: true);
}
catch (Exception ex)
{
return StatusCode(500, new { error = $"Error streaming from Subsonic: {ex.Message}" });
}
}
private IActionResult MergeSearchResults(
(byte[]? Body, string? ContentType, bool Success) subsonicResult,
SearchResult externalResult,
string format)
{
var localSongs = new List<object>();
var localAlbums = new List<object>();
var localArtists = new List<object>();
var (localSongs, localAlbums, localArtists) = subsonicResult.Success && subsonicResult.Body != null
? _modelMapper.ParseSearchResponse(subsonicResult.Body, subsonicResult.ContentType)
: (new List<object>(), new List<object>(), new List<object>());
if (subsonicResult.Success && subsonicResult.Body != null)
var isJson = format == "json" || subsonicResult.ContentType?.Contains("json") == true;
var (mergedSongs, mergedAlbums, mergedArtists) = _modelMapper.MergeSearchResults(
localSongs,
localAlbums,
localArtists,
externalResult,
isJson);
if (isJson)
{
try
{
var subsonicContent = Encoding.UTF8.GetString(subsonicResult.Body);
if (format == "json" || subsonicResult.ContentType?.Contains("json") == true)
{
var jsonDoc = JsonDocument.Parse(subsonicContent);
if (jsonDoc.RootElement.TryGetProperty("subsonic-response", out var response) &&
response.TryGetProperty("searchResult3", out var searchResult))
{
if (searchResult.TryGetProperty("song", out var songs))
{
foreach (var song in songs.EnumerateArray())
{
localSongs.Add(ConvertSubsonicJsonElement(song, true));
}
}
if (searchResult.TryGetProperty("album", out var albums))
{
foreach (var album in albums.EnumerateArray())
{
localAlbums.Add(ConvertSubsonicJsonElement(album, true));
}
}
if (searchResult.TryGetProperty("artist", out var artists))
{
foreach (var artist in artists.EnumerateArray())
{
localArtists.Add(ConvertSubsonicJsonElement(artist, true));
}
}
}
}
else
{
var xmlDoc = XDocument.Parse(subsonicContent);
var ns = xmlDoc.Root?.GetDefaultNamespace() ?? XNamespace.None;
var searchResult = xmlDoc.Descendants(ns + "searchResult3").FirstOrDefault();
if (searchResult != null)
{
foreach (var song in searchResult.Elements(ns + "song"))
{
localSongs.Add(ConvertSubsonicXmlElement(song, "song"));
}
foreach (var album in searchResult.Elements(ns + "album"))
{
localAlbums.Add(ConvertSubsonicXmlElement(album, "album"));
}
foreach (var artist in searchResult.Elements(ns + "artist"))
{
localArtists.Add(ConvertSubsonicXmlElement(artist, "artist"));
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error parsing Subsonic response");
}
}
if (format == "json")
{
var mergedSongs = localSongs
.Concat(externalResult.Songs.Select(s => ConvertSongToSubsonicJson(s)))
.ToList();
var mergedAlbums = localAlbums
.Concat(externalResult.Albums.Select(a => ConvertAlbumToSubsonicJson(a)))
.ToList();
// Deduplicate artists by name - prefer local artists over external ones
var localArtistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var artist in localArtists)
{
if (artist is Dictionary<string, object> dict && dict.TryGetValue("name", out var nameObj))
{
localArtistNames.Add(nameObj?.ToString() ?? "");
}
}
var mergedArtists = localArtists.ToList();
foreach (var externalArtist in externalResult.Artists)
{
// Only add external artist if no local artist with same name exists
if (!localArtistNames.Contains(externalArtist.Name))
{
mergedArtists.Add(ConvertArtistToSubsonicJson(externalArtist));
}
}
return CreateSubsonicJsonResponse(new
return _responseBuilder.CreateJsonResponse(new
{
status = "ok",
version = "1.16.1",
@@ -789,49 +600,20 @@ public class SubsonicController : ControllerBase
else
{
var ns = XNamespace.Get("http://subsonic.org/restapi");
var searchResult3 = new XElement(ns + "searchResult3");
// Deduplicate artists by name - prefer local artists over external ones
var localArtistNamesXml = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var artist in localArtists.Cast<XElement>())
foreach (var artist in mergedArtists.Cast<XElement>())
{
var name = artist.Attribute("name")?.Value;
if (!string.IsNullOrEmpty(name))
{
localArtistNamesXml.Add(name);
}
artist.Name = ns + "artist";
searchResult3.Add(artist);
}
foreach (var artist in externalResult.Artists)
foreach (var album in mergedAlbums.Cast<XElement>())
{
// Only add external artist if no local artist with same name exists
if (!localArtistNamesXml.Contains(artist.Name))
{
searchResult3.Add(ConvertArtistToSubsonicXml(artist, ns));
}
}
foreach (var album in localAlbums.Cast<XElement>())
{
album.Name = ns + "album";
searchResult3.Add(album);
}
foreach (var album in externalResult.Albums)
foreach (var song in mergedSongs.Cast<XElement>())
{
searchResult3.Add(ConvertAlbumToSubsonicXml(album, ns));
}
foreach (var song in localSongs.Cast<XElement>())
{
song.Name = ns + "song";
searchResult3.Add(song);
}
foreach (var song in externalResult.Songs)
{
searchResult3.Add(ConvertSongToSubsonicXml(song, ns));
}
var doc = new XDocument(
new XElement(ns + "subsonic-response",
@@ -845,296 +627,6 @@ public class SubsonicController : ControllerBase
}
}
private object ConvertSubsonicJsonElement(JsonElement element, bool isLocal)
{
var dict = new Dictionary<string, object>();
foreach (var prop in element.EnumerateObject())
{
dict[prop.Name] = ConvertJsonValue(prop.Value);
}
dict["isExternal"] = !isLocal;
return dict;
}
private object ConvertJsonValue(JsonElement value)
{
return value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? "",
JsonValueKind.Number => value.TryGetInt32(out var i) ? i : value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Array => value.EnumerateArray().Select(ConvertJsonValue).ToList(),
JsonValueKind.Object => value.EnumerateObject().ToDictionary(p => p.Name, p => ConvertJsonValue(p.Value)),
JsonValueKind.Null => null!,
_ => value.ToString()
};
}
private XElement ConvertSubsonicXmlElement(XElement element, string type)
{
var newElement = new XElement(element);
newElement.SetAttributeValue("isExternal", "false");
return newElement;
}
private Dictionary<string, object> ConvertSongToSubsonicJson(Song song)
{
var result = new Dictionary<string, object>
{
["id"] = song.Id,
["parent"] = song.AlbumId ?? "",
["isDir"] = false,
["title"] = song.Title,
["album"] = song.Album ?? "",
["artist"] = song.Artist ?? "",
["albumId"] = song.AlbumId ?? "",
["artistId"] = song.ArtistId ?? "",
["duration"] = song.Duration ?? 0,
["track"] = song.Track ?? 0,
["year"] = song.Year ?? 0,
["coverArt"] = song.Id,
["suffix"] = song.IsLocal ? "mp3" : "Remote",
["contentType"] = "audio/mpeg",
["type"] = "music",
["isVideo"] = false,
["isExternal"] = !song.IsLocal
};
result["bitRate"] = song.IsLocal ? 128 : 0; // Default bitrate for local files
return result;
}
private object ConvertAlbumToSubsonicJson(Album album)
{
return new
{
id = album.Id,
name = album.Title,
artist = album.Artist,
artistId = album.ArtistId,
songCount = album.SongCount ?? 0,
year = album.Year ?? 0,
coverArt = album.Id,
isExternal = !album.IsLocal
};
}
private object ConvertArtistToSubsonicJson(Artist artist)
{
return new
{
id = artist.Id,
name = artist.Name,
albumCount = artist.AlbumCount ?? 0,
coverArt = artist.Id,
isExternal = !artist.IsLocal
};
}
private XElement ConvertSongToSubsonicXml(Song song, XNamespace ns)
{
return new XElement(ns + "song",
new XAttribute("id", song.Id),
new XAttribute("title", song.Title),
new XAttribute("album", song.Album ?? ""),
new XAttribute("artist", song.Artist ?? ""),
new XAttribute("duration", song.Duration ?? 0),
new XAttribute("track", song.Track ?? 0),
new XAttribute("year", song.Year ?? 0),
new XAttribute("coverArt", song.Id),
new XAttribute("isExternal", (!song.IsLocal).ToString().ToLower())
);
}
private XElement ConvertAlbumToSubsonicXml(Album album, XNamespace ns)
{
return new XElement(ns + "album",
new XAttribute("id", album.Id),
new XAttribute("name", album.Title),
new XAttribute("artist", album.Artist ?? ""),
new XAttribute("songCount", album.SongCount ?? 0),
new XAttribute("year", album.Year ?? 0),
new XAttribute("coverArt", album.Id),
new XAttribute("isExternal", (!album.IsLocal).ToString().ToLower())
);
}
private XElement ConvertArtistToSubsonicXml(Artist artist, XNamespace ns)
{
return new XElement(ns + "artist",
new XAttribute("id", artist.Id),
new XAttribute("name", artist.Name),
new XAttribute("albumCount", artist.AlbumCount ?? 0),
new XAttribute("coverArt", artist.Id),
new XAttribute("isExternal", (!artist.IsLocal).ToString().ToLower())
);
}
/// <summary>
/// Creates a JSON Subsonic response with "subsonic-response" key (with hyphen).
/// </summary>
private IActionResult CreateSubsonicJsonResponse(object responseContent)
{
var response = new Dictionary<string, object>
{
["subsonic-response"] = responseContent
};
return new JsonResult(response);
}
private IActionResult CreateSubsonicResponse(string format, string elementName, object data)
{
if (format == "json")
{
return CreateSubsonicJsonResponse(new { status = "ok", version = "1.16.1" });
}
var ns = XNamespace.Get("http://subsonic.org/restapi");
var doc = new XDocument(
new XElement(ns + "subsonic-response",
new XAttribute("status", "ok"),
new XAttribute("version", "1.16.1"),
new XElement(ns + elementName)
)
);
return Content(doc.ToString(), "application/xml");
}
private IActionResult CreateSubsonicError(string format, int code, string message)
{
if (format == "json")
{
return CreateSubsonicJsonResponse(new
{
status = "failed",
version = "1.16.1",
error = new { code, message }
});
}
var ns = XNamespace.Get("http://subsonic.org/restapi");
var doc = new XDocument(
new XElement(ns + "subsonic-response",
new XAttribute("status", "failed"),
new XAttribute("version", "1.16.1"),
new XElement(ns + "error",
new XAttribute("code", code),
new XAttribute("message", message)
)
)
);
return Content(doc.ToString(), "application/xml");
}
private IActionResult CreateSubsonicSongResponse(string format, Song song)
{
if (format == "json")
{
return CreateSubsonicJsonResponse(new
{
status = "ok",
version = "1.16.1",
song = ConvertSongToSubsonicJson(song)
});
}
var ns = XNamespace.Get("http://subsonic.org/restapi");
var doc = new XDocument(
new XElement(ns + "subsonic-response",
new XAttribute("status", "ok"),
new XAttribute("version", "1.16.1"),
ConvertSongToSubsonicXml(song, ns)
)
);
return Content(doc.ToString(), "application/xml");
}
private IActionResult CreateSubsonicAlbumResponse(string format, Album album)
{
// Calculate total duration from songs
var totalDuration = album.Songs.Sum(s => s.Duration ?? 0);
if (format == "json")
{
return CreateSubsonicJsonResponse(new
{
status = "ok",
version = "1.16.1",
album = new
{
id = album.Id,
name = album.Title,
artist = album.Artist,
artistId = album.ArtistId,
coverArt = album.Id,
songCount = album.Songs.Count > 0 ? album.Songs.Count : (album.SongCount ?? 0),
duration = totalDuration,
year = album.Year ?? 0,
genre = album.Genre ?? "",
isCompilation = false,
song = album.Songs.Select(s => ConvertSongToSubsonicJson(s)).ToList()
}
});
}
var ns = XNamespace.Get("http://subsonic.org/restapi");
var doc = new XDocument(
new XElement(ns + "subsonic-response",
new XAttribute("status", "ok"),
new XAttribute("version", "1.16.1"),
new XElement(ns + "album",
new XAttribute("id", album.Id),
new XAttribute("name", album.Title),
new XAttribute("artist", album.Artist ?? ""),
new XAttribute("songCount", album.SongCount ?? 0),
new XAttribute("year", album.Year ?? 0),
new XAttribute("coverArt", album.Id),
album.Songs.Select(s => ConvertSongToSubsonicXml(s, ns))
)
)
);
return Content(doc.ToString(), "application/xml");
}
private IActionResult CreateSubsonicArtistResponse(string format, Artist artist, List<Album> albums)
{
if (format == "json")
{
return CreateSubsonicJsonResponse(new
{
status = "ok",
version = "1.16.1",
artist = new
{
id = artist.Id,
name = artist.Name,
coverArt = artist.Id,
albumCount = albums.Count,
artistImageUrl = artist.ImageUrl,
album = albums.Select(a => ConvertAlbumToSubsonicJson(a)).ToList()
}
});
}
var ns = XNamespace.Get("http://subsonic.org/restapi");
var doc = new XDocument(
new XElement(ns + "subsonic-response",
new XAttribute("status", "ok"),
new XAttribute("version", "1.16.1"),
new XElement(ns + "artist",
new XAttribute("id", artist.Id),
new XAttribute("name", artist.Name),
new XAttribute("coverArt", artist.Id),
new XAttribute("albumCount", albums.Count),
albums.Select(a => ConvertAlbumToSubsonicXml(a, ns))
)
)
);
return Content(doc.ToString(), "application/xml");
}
private string GetContentType(string filePath)
{
var extension = Path.GetExtension(filePath).ToLowerInvariant();
@@ -1162,14 +654,14 @@ public class SubsonicController : ControllerBase
try
{
var result = await RelayToSubsonic(endpoint, parameters);
var result = await _proxyService.RelayAsync(endpoint, parameters);
var contentType = result.ContentType ?? $"application/{format}";
return File((byte[])result.Body, contentType);
return File(result.Body, contentType);
}
catch (HttpRequestException ex)
{
// Return Subsonic-compatible error response
return CreateSubsonicError(format, 0, $"Error connecting to Subsonic server: {ex.Message}");
return _responseBuilder.CreateError(format, 0, $"Error connecting to Subsonic server: {ex.Message}");
}
}
}

View File

@@ -4,6 +4,7 @@ using octo_fiesta.Services.Deezer;
using octo_fiesta.Services.Qobuz;
using octo_fiesta.Services.Local;
using octo_fiesta.Services.Validation;
using octo_fiesta.Services.Subsonic;
using octo_fiesta.Middleware;
var builder = WebApplication.CreateBuilder(args);
@@ -34,6 +35,12 @@ var musicService = builder.Configuration.GetValue<MusicService>("Subsonic:MusicS
// Registered as Singleton to share state (mappings cache, scan debounce, download tracking, rate limiting)
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
// Subsonic services
builder.Services.AddSingleton<SubsonicRequestParser>();
builder.Services.AddSingleton<SubsonicResponseBuilder>();
builder.Services.AddSingleton<SubsonicModelMapper>();
builder.Services.AddSingleton<SubsonicProxyService>();
// Register music service based on configuration
if (musicService == MusicService.Qobuz)
{

View File

@@ -0,0 +1,214 @@
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
using octo_fiesta.Models.Search;
namespace octo_fiesta.Services.Subsonic;
/// <summary>
/// Handles parsing Subsonic API responses and merging local with external search results.
/// </summary>
public class SubsonicModelMapper
{
private readonly SubsonicResponseBuilder _responseBuilder;
private readonly ILogger<SubsonicModelMapper> _logger;
public SubsonicModelMapper(
SubsonicResponseBuilder responseBuilder,
ILogger<SubsonicModelMapper> logger)
{
_responseBuilder = responseBuilder;
_logger = logger;
}
/// <summary>
/// Parses a Subsonic search response and extracts songs, albums, and artists.
/// </summary>
public (List<object> Songs, List<object> Albums, List<object> Artists) ParseSearchResponse(
byte[] responseBody,
string? contentType)
{
var songs = new List<object>();
var albums = new List<object>();
var artists = new List<object>();
try
{
var content = Encoding.UTF8.GetString(responseBody);
if (contentType?.Contains("json") == true)
{
var jsonDoc = JsonDocument.Parse(content);
if (jsonDoc.RootElement.TryGetProperty("subsonic-response", out var response) &&
response.TryGetProperty("searchResult3", out var searchResult))
{
if (searchResult.TryGetProperty("song", out var songElements))
{
foreach (var song in songElements.EnumerateArray())
{
songs.Add(_responseBuilder.ConvertSubsonicJsonElement(song, true));
}
}
if (searchResult.TryGetProperty("album", out var albumElements))
{
foreach (var album in albumElements.EnumerateArray())
{
albums.Add(_responseBuilder.ConvertSubsonicJsonElement(album, true));
}
}
if (searchResult.TryGetProperty("artist", out var artistElements))
{
foreach (var artist in artistElements.EnumerateArray())
{
artists.Add(_responseBuilder.ConvertSubsonicJsonElement(artist, true));
}
}
}
}
else
{
var xmlDoc = XDocument.Parse(content);
var ns = xmlDoc.Root?.GetDefaultNamespace() ?? XNamespace.None;
var searchResult = xmlDoc.Descendants(ns + "searchResult3").FirstOrDefault();
if (searchResult != null)
{
foreach (var song in searchResult.Elements(ns + "song"))
{
songs.Add(_responseBuilder.ConvertSubsonicXmlElement(song, "song"));
}
foreach (var album in searchResult.Elements(ns + "album"))
{
albums.Add(_responseBuilder.ConvertSubsonicXmlElement(album, "album"));
}
foreach (var artist in searchResult.Elements(ns + "artist"))
{
artists.Add(_responseBuilder.ConvertSubsonicXmlElement(artist, "artist"));
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error parsing Subsonic search response");
}
return (songs, albums, artists);
}
/// <summary>
/// Merges local search results with external search results, deduplicating by name.
/// </summary>
public (List<object> MergedSongs, List<object> MergedAlbums, List<object> MergedArtists) MergeSearchResults(
List<object> localSongs,
List<object> localAlbums,
List<object> localArtists,
SearchResult externalResult,
bool isJson)
{
if (isJson)
{
return MergeSearchResultsJson(localSongs, localAlbums, localArtists, externalResult);
}
else
{
return MergeSearchResultsXml(localSongs, localAlbums, localArtists, externalResult);
}
}
private (List<object> MergedSongs, List<object> MergedAlbums, List<object> MergedArtists) MergeSearchResultsJson(
List<object> localSongs,
List<object> localAlbums,
List<object> localArtists,
SearchResult externalResult)
{
var mergedSongs = localSongs
.Concat(externalResult.Songs.Select(s => _responseBuilder.ConvertSongToJson(s)))
.ToList();
var mergedAlbums = localAlbums
.Concat(externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJson(a)))
.ToList();
// Deduplicate artists by name - prefer local artists over external ones
var localArtistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var artist in localArtists)
{
if (artist is Dictionary<string, object> dict && dict.TryGetValue("name", out var nameObj))
{
localArtistNames.Add(nameObj?.ToString() ?? "");
}
}
var mergedArtists = localArtists.ToList();
foreach (var externalArtist in externalResult.Artists)
{
// Only add external artist if no local artist with same name exists
if (!localArtistNames.Contains(externalArtist.Name))
{
mergedArtists.Add(_responseBuilder.ConvertArtistToJson(externalArtist));
}
}
return (mergedSongs, mergedAlbums, mergedArtists);
}
private (List<object> MergedSongs, List<object> MergedAlbums, List<object> MergedArtists) MergeSearchResultsXml(
List<object> localSongs,
List<object> localAlbums,
List<object> localArtists,
SearchResult externalResult)
{
var ns = XNamespace.Get("http://subsonic.org/restapi");
// Deduplicate artists by name - prefer local artists over external ones
var localArtistNamesXml = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var mergedArtists = new List<object>();
foreach (var artist in localArtists.Cast<XElement>())
{
var name = artist.Attribute("name")?.Value;
if (!string.IsNullOrEmpty(name))
{
localArtistNamesXml.Add(name);
}
artist.Name = ns + "artist";
mergedArtists.Add(artist);
}
foreach (var artist in externalResult.Artists)
{
// Only add external artist if no local artist with same name exists
if (!localArtistNamesXml.Contains(artist.Name))
{
mergedArtists.Add(_responseBuilder.ConvertArtistToXml(artist, ns));
}
}
// Albums
var mergedAlbums = new List<object>();
foreach (var album in localAlbums.Cast<XElement>())
{
album.Name = ns + "album";
mergedAlbums.Add(album);
}
foreach (var album in externalResult.Albums)
{
mergedAlbums.Add(_responseBuilder.ConvertAlbumToXml(album, ns));
}
// Songs
var mergedSongs = new List<object>();
foreach (var song in localSongs.Cast<XElement>())
{
song.Name = ns + "song";
mergedSongs.Add(song);
}
foreach (var song in externalResult.Songs)
{
mergedSongs.Add(_responseBuilder.ConvertSongToXml(song, ns));
}
return (mergedSongs, mergedAlbums, mergedArtists);
}
}

View File

@@ -0,0 +1,100 @@
using Microsoft.AspNetCore.Mvc;
using octo_fiesta.Models.Settings;
namespace octo_fiesta.Services.Subsonic;
/// <summary>
/// Handles proxying requests to the underlying Subsonic server.
/// </summary>
public class SubsonicProxyService
{
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _subsonicSettings;
public SubsonicProxyService(
IHttpClientFactory httpClientFactory,
Microsoft.Extensions.Options.IOptions<SubsonicSettings> subsonicSettings)
{
_httpClient = httpClientFactory.CreateClient();
_subsonicSettings = subsonicSettings.Value;
}
/// <summary>
/// Relays a request to the Subsonic server and returns the response.
/// </summary>
public async Task<(byte[] Body, string? ContentType)> RelayAsync(
string endpoint,
Dictionary<string, string> parameters)
{
var query = string.Join("&", parameters.Select(kv =>
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{_subsonicSettings.Url}/{endpoint}?{query}";
HttpResponseMessage response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString();
return (body, contentType);
}
/// <summary>
/// Safely relays a request to the Subsonic server, returning null on failure.
/// </summary>
public async Task<(byte[]? Body, string? ContentType, bool Success)> RelaySafeAsync(
string endpoint,
Dictionary<string, string> parameters)
{
try
{
var result = await RelayAsync(endpoint, parameters);
return (result.Body, result.ContentType, true);
}
catch
{
return (null, null, false);
}
}
/// <summary>
/// Relays a stream request to the Subsonic server with range processing support.
/// </summary>
public async Task<IActionResult> RelayStreamAsync(
Dictionary<string, string> parameters,
CancellationToken cancellationToken)
{
try
{
var query = string.Join("&", parameters.Select(kv =>
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{_subsonicSettings.Url}/rest/stream?{query}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new StatusCodeResult((int)response.StatusCode);
}
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
return new FileStreamResult(stream, contentType)
{
EnableRangeProcessing = true
};
}
catch (Exception ex)
{
return new ObjectResult(new { error = $"Error streaming from Subsonic: {ex.Message}" })
{
StatusCode = 500
};
}
}
}

View File

@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.WebUtilities;
using System.Text.Json;
namespace octo_fiesta.Services.Subsonic;
/// <summary>
/// Service responsible for parsing HTTP request parameters from various sources
/// (query string, form body, JSON body) for Subsonic API requests.
/// </summary>
public class SubsonicRequestParser
{
/// <summary>
/// Extracts all parameters from an HTTP request (query parameters + body parameters).
/// Supports multiple content types: application/x-www-form-urlencoded and application/json.
/// </summary>
/// <param name="request">The HTTP request to parse</param>
/// <returns>Dictionary containing all extracted parameters</returns>
public async Task<Dictionary<string, string>> ExtractAllParametersAsync(HttpRequest request)
{
var parameters = new Dictionary<string, string>();
// Get query parameters
foreach (var query in request.Query)
{
parameters[query.Key] = query.Value.ToString();
}
// Get body parameters
if (request.ContentLength > 0 || request.ContentType != null)
{
// Handle application/x-www-form-urlencoded (OpenSubsonic formPost extension)
if (request.HasFormContentType)
{
await ExtractFormParametersAsync(request, parameters);
}
// Handle application/json
else if (request.ContentType?.Contains("application/json") == true)
{
await ExtractJsonParametersAsync(request, parameters);
}
}
return parameters;
}
/// <summary>
/// Extracts parameters from form-encoded request body.
/// </summary>
private async Task ExtractFormParametersAsync(HttpRequest request, Dictionary<string, string> parameters)
{
try
{
var form = await request.ReadFormAsync();
foreach (var field in form)
{
parameters[field.Key] = field.Value.ToString();
}
}
catch
{
// Fall back to manual parsing if ReadFormAsync fails
request.EnableBuffering();
using var reader = new StreamReader(request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
request.Body.Position = 0;
if (!string.IsNullOrEmpty(body))
{
var formParams = QueryHelpers.ParseQuery(body);
foreach (var param in formParams)
{
parameters[param.Key] = param.Value.ToString();
}
}
}
}
/// <summary>
/// Extracts parameters from JSON request body.
/// </summary>
private async Task ExtractJsonParametersAsync(HttpRequest request, Dictionary<string, string> parameters)
{
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync();
if (!string.IsNullOrEmpty(body))
{
try
{
var bodyParams = JsonSerializer.Deserialize<Dictionary<string, object>>(body);
if (bodyParams != null)
{
foreach (var param in bodyParams)
{
parameters[param.Key] = param.Value?.ToString() ?? "";
}
}
}
catch (JsonException)
{
// Ignore JSON parsing errors
}
}
}
}

View File

@@ -0,0 +1,343 @@
using Microsoft.AspNetCore.Mvc;
using System.Xml.Linq;
using System.Text.Json;
using octo_fiesta.Models.Domain;
namespace octo_fiesta.Services.Subsonic;
/// <summary>
/// Handles building Subsonic API responses in both XML and JSON formats.
/// </summary>
public class SubsonicResponseBuilder
{
private const string SubsonicNamespace = "http://subsonic.org/restapi";
private const string SubsonicVersion = "1.16.1";
/// <summary>
/// Creates a generic Subsonic response with status "ok".
/// </summary>
public IActionResult CreateResponse(string format, string elementName, object data)
{
if (format == "json")
{
return CreateJsonResponse(new { status = "ok", version = SubsonicVersion });
}
var ns = XNamespace.Get(SubsonicNamespace);
var doc = new XDocument(
new XElement(ns + "subsonic-response",
new XAttribute("status", "ok"),
new XAttribute("version", SubsonicVersion),
new XElement(ns + elementName)
)
);
return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" };
}
/// <summary>
/// Creates a Subsonic error response.
/// </summary>
public IActionResult CreateError(string format, int code, string message)
{
if (format == "json")
{
return CreateJsonResponse(new
{
status = "failed",
version = SubsonicVersion,
error = new { code, message }
});
}
var ns = XNamespace.Get(SubsonicNamespace);
var doc = new XDocument(
new XElement(ns + "subsonic-response",
new XAttribute("status", "failed"),
new XAttribute("version", SubsonicVersion),
new XElement(ns + "error",
new XAttribute("code", code),
new XAttribute("message", message)
)
)
);
return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" };
}
/// <summary>
/// Creates a Subsonic response containing a single song.
/// </summary>
public IActionResult CreateSongResponse(string format, Song song)
{
if (format == "json")
{
return CreateJsonResponse(new
{
status = "ok",
version = SubsonicVersion,
song = ConvertSongToJson(song)
});
}
var ns = XNamespace.Get(SubsonicNamespace);
var doc = new XDocument(
new XElement(ns + "subsonic-response",
new XAttribute("status", "ok"),
new XAttribute("version", SubsonicVersion),
ConvertSongToXml(song, ns)
)
);
return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" };
}
/// <summary>
/// Creates a Subsonic response containing an album with songs.
/// </summary>
public IActionResult CreateAlbumResponse(string format, Album album)
{
var totalDuration = album.Songs.Sum(s => s.Duration ?? 0);
if (format == "json")
{
return CreateJsonResponse(new
{
status = "ok",
version = SubsonicVersion,
album = new
{
id = album.Id,
name = album.Title,
artist = album.Artist,
artistId = album.ArtistId,
coverArt = album.Id,
songCount = album.Songs.Count > 0 ? album.Songs.Count : (album.SongCount ?? 0),
duration = totalDuration,
year = album.Year ?? 0,
genre = album.Genre ?? "",
isCompilation = false,
song = album.Songs.Select(s => ConvertSongToJson(s)).ToList()
}
});
}
var ns = XNamespace.Get(SubsonicNamespace);
var doc = new XDocument(
new XElement(ns + "subsonic-response",
new XAttribute("status", "ok"),
new XAttribute("version", SubsonicVersion),
new XElement(ns + "album",
new XAttribute("id", album.Id),
new XAttribute("name", album.Title),
new XAttribute("artist", album.Artist ?? ""),
new XAttribute("songCount", album.SongCount ?? 0),
new XAttribute("year", album.Year ?? 0),
new XAttribute("coverArt", album.Id),
album.Songs.Select(s => ConvertSongToXml(s, ns))
)
)
);
return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" };
}
/// <summary>
/// Creates a Subsonic response containing an artist with albums.
/// </summary>
public IActionResult CreateArtistResponse(string format, Artist artist, List<Album> albums)
{
if (format == "json")
{
return CreateJsonResponse(new
{
status = "ok",
version = SubsonicVersion,
artist = new
{
id = artist.Id,
name = artist.Name,
coverArt = artist.Id,
albumCount = albums.Count,
artistImageUrl = artist.ImageUrl,
album = albums.Select(a => ConvertAlbumToJson(a)).ToList()
}
});
}
var ns = XNamespace.Get(SubsonicNamespace);
var doc = new XDocument(
new XElement(ns + "subsonic-response",
new XAttribute("status", "ok"),
new XAttribute("version", SubsonicVersion),
new XElement(ns + "artist",
new XAttribute("id", artist.Id),
new XAttribute("name", artist.Name),
new XAttribute("coverArt", artist.Id),
new XAttribute("albumCount", albums.Count),
albums.Select(a => ConvertAlbumToXml(a, ns))
)
)
);
return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" };
}
/// <summary>
/// Creates a JSON Subsonic response with "subsonic-response" key (with hyphen).
/// </summary>
public IActionResult CreateJsonResponse(object responseContent)
{
var response = new Dictionary<string, object>
{
["subsonic-response"] = responseContent
};
return new JsonResult(response);
}
/// <summary>
/// Converts a Song domain model to Subsonic JSON format.
/// </summary>
public Dictionary<string, object> ConvertSongToJson(Song song)
{
var result = new Dictionary<string, object>
{
["id"] = song.Id,
["parent"] = song.AlbumId ?? "",
["isDir"] = false,
["title"] = song.Title,
["album"] = song.Album ?? "",
["artist"] = song.Artist ?? "",
["albumId"] = song.AlbumId ?? "",
["artistId"] = song.ArtistId ?? "",
["duration"] = song.Duration ?? 0,
["track"] = song.Track ?? 0,
["year"] = song.Year ?? 0,
["coverArt"] = song.Id,
["suffix"] = song.IsLocal ? "mp3" : "Remote",
["contentType"] = "audio/mpeg",
["type"] = "music",
["isVideo"] = false,
["isExternal"] = !song.IsLocal
};
result["bitRate"] = song.IsLocal ? 128 : 0; // Default bitrate for local files
return result;
}
/// <summary>
/// Converts an Album domain model to Subsonic JSON format.
/// </summary>
public object ConvertAlbumToJson(Album album)
{
return new
{
id = album.Id,
name = album.Title,
artist = album.Artist,
artistId = album.ArtistId,
songCount = album.SongCount ?? 0,
year = album.Year ?? 0,
coverArt = album.Id,
isExternal = !album.IsLocal
};
}
/// <summary>
/// Converts an Artist domain model to Subsonic JSON format.
/// </summary>
public object ConvertArtistToJson(Artist artist)
{
return new
{
id = artist.Id,
name = artist.Name,
albumCount = artist.AlbumCount ?? 0,
coverArt = artist.Id,
isExternal = !artist.IsLocal
};
}
/// <summary>
/// Converts a Song domain model to Subsonic XML format.
/// </summary>
public XElement ConvertSongToXml(Song song, XNamespace ns)
{
return new XElement(ns + "song",
new XAttribute("id", song.Id),
new XAttribute("title", song.Title),
new XAttribute("album", song.Album ?? ""),
new XAttribute("artist", song.Artist ?? ""),
new XAttribute("duration", song.Duration ?? 0),
new XAttribute("track", song.Track ?? 0),
new XAttribute("year", song.Year ?? 0),
new XAttribute("coverArt", song.Id),
new XAttribute("isExternal", (!song.IsLocal).ToString().ToLower())
);
}
/// <summary>
/// Converts an Album domain model to Subsonic XML format.
/// </summary>
public XElement ConvertAlbumToXml(Album album, XNamespace ns)
{
return new XElement(ns + "album",
new XAttribute("id", album.Id),
new XAttribute("name", album.Title),
new XAttribute("artist", album.Artist ?? ""),
new XAttribute("songCount", album.SongCount ?? 0),
new XAttribute("year", album.Year ?? 0),
new XAttribute("coverArt", album.Id),
new XAttribute("isExternal", (!album.IsLocal).ToString().ToLower())
);
}
/// <summary>
/// Converts an Artist domain model to Subsonic XML format.
/// </summary>
public XElement ConvertArtistToXml(Artist artist, XNamespace ns)
{
return new XElement(ns + "artist",
new XAttribute("id", artist.Id),
new XAttribute("name", artist.Name),
new XAttribute("albumCount", artist.AlbumCount ?? 0),
new XAttribute("coverArt", artist.Id),
new XAttribute("isExternal", (!artist.IsLocal).ToString().ToLower())
);
}
/// <summary>
/// Converts a Subsonic JSON element to a dictionary.
/// </summary>
public object ConvertSubsonicJsonElement(JsonElement element, bool isLocal)
{
var dict = new Dictionary<string, object>();
foreach (var prop in element.EnumerateObject())
{
dict[prop.Name] = ConvertJsonValue(prop.Value);
}
dict["isExternal"] = !isLocal;
return dict;
}
/// <summary>
/// Converts a Subsonic XML element.
/// </summary>
public XElement ConvertSubsonicXmlElement(XElement element, string type)
{
var newElement = new XElement(element);
newElement.SetAttributeValue("isExternal", "false");
return newElement;
}
private object ConvertJsonValue(JsonElement value)
{
return value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? "",
JsonValueKind.Number => value.TryGetInt32(out var i) ? i : value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Array => value.EnumerateArray().Select(ConvertJsonValue).ToList(),
JsonValueKind.Object => value.EnumerateObject().ToDictionary(p => p.Name, p => ConvertJsonValue(p.Value)),
JsonValueKind.Null => null!,
_ => value.ToString()
};
}
}