From 9245dac99e60f2152cf1f3fbbdd700a7b549209a Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Thu, 8 Jan 2026 21:47:05 +0100 Subject: [PATCH] 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. --- octo-fiesta.Tests/SubsonicModelMapperTests.cs | 347 ++++++++++ .../SubsonicProxyServiceTests.cs | 325 +++++++++ .../SubsonicRequestParserTests.cs | 202 ++++++ .../SubsonicResponseBuilderTests.cs | 322 +++++++++ octo-fiesta/Controllers/SubSonicController.cs | 640 ++---------------- octo-fiesta/Program.cs | 7 + .../Services/Subsonic/SubsonicModelMapper.cs | 214 ++++++ .../Services/Subsonic/SubsonicProxyService.cs | 100 +++ .../Subsonic/SubsonicRequestParser.cs | 105 +++ .../Subsonic/SubsonicResponseBuilder.cs | 343 ++++++++++ 10 files changed, 2031 insertions(+), 574 deletions(-) create mode 100644 octo-fiesta.Tests/SubsonicModelMapperTests.cs create mode 100644 octo-fiesta.Tests/SubsonicProxyServiceTests.cs create mode 100644 octo-fiesta.Tests/SubsonicRequestParserTests.cs create mode 100644 octo-fiesta.Tests/SubsonicResponseBuilderTests.cs create mode 100644 octo-fiesta/Services/Subsonic/SubsonicModelMapper.cs create mode 100644 octo-fiesta/Services/Subsonic/SubsonicProxyService.cs create mode 100644 octo-fiesta/Services/Subsonic/SubsonicRequestParser.cs create mode 100644 octo-fiesta/Services/Subsonic/SubsonicResponseBuilder.cs diff --git a/octo-fiesta.Tests/SubsonicModelMapperTests.cs b/octo-fiesta.Tests/SubsonicModelMapperTests.cs new file mode 100644 index 0000000..98249b8 --- /dev/null +++ b/octo-fiesta.Tests/SubsonicModelMapperTests.cs @@ -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> _mockLogger; + private readonly SubsonicResponseBuilder _responseBuilder; + + public SubsonicModelMapperTests() + { + _responseBuilder = new SubsonicResponseBuilder(); + _mockLogger = new Mock>(); + _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 = @" + + + + +"; + 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 = @" + + + + + + +"; + 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 + { + new Dictionary { ["id"] = "local1", ["title"] = "Local Song" } + }; + var externalResult = new SearchResult + { + Songs = new List + { + new Song { Id = "ext1", Title = "External Song" } + }, + Albums = new List(), + Artists = new List() + }; + + // Act + var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( + localSongs, new List(), new List(), externalResult, true); + + // Assert + Assert.Equal(2, mergedSongs.Count); + } + + [Fact] + public void MergeSearchResults_Json_DeduplicatesArtists() + { + // Arrange + var localArtists = new List + { + new Dictionary { ["id"] = "local1", ["name"] = "Test Artist" } + }; + var externalResult = new SearchResult + { + Songs = new List(), + Albums = new List(), + Artists = new List + { + 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(), new List(), 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 + { + new Dictionary { ["id"] = "local1", ["name"] = "Test Artist" } + }; + var externalResult = new SearchResult + { + Songs = new List(), + Albums = new List(), + Artists = new List + { + new Artist { Id = "ext1", Name = "test artist" } // Different case - should still be filtered + } + }; + + // Act + var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( + new List(), new List(), 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 + { + new XElement("song", new XAttribute("id", "local1"), new XAttribute("title", "Local Song")) + }; + var externalResult = new SearchResult + { + Songs = new List + { + new Song { Id = "ext1", Title = "External Song" } + }, + Albums = new List(), + Artists = new List() + }; + + // Act + var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( + localSongs, new List(), new List(), externalResult, false); + + // Assert + Assert.Equal(2, mergedSongs.Count); + } + + [Fact] + public void MergeSearchResults_Xml_DeduplicatesArtists() + { + // Arrange + var localArtists = new List + { + new XElement("artist", new XAttribute("id", "local1"), new XAttribute("name", "Test Artist")) + }; + var externalResult = new SearchResult + { + Songs = new List(), + Albums = new List(), + Artists = new List + { + 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(), new List(), 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 { new Song { Id = "ext1" } }, + Albums = new List { new Album { Id = "ext2" } }, + Artists = new List { new Artist { Id = "ext3", Name = "Artist" } } + }; + + // Act + var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( + new List(), new List(), new List(), externalResult, true); + + // Assert + Assert.Single(mergedSongs); + Assert.Single(mergedAlbums); + Assert.Single(mergedArtists); + } + + [Fact] + public void MergeSearchResults_EmptyExternalResults_ReturnsOnlyLocal() + { + // Arrange + var localSongs = new List { new Dictionary { ["id"] = "local1" } }; + var localAlbums = new List { new Dictionary { ["id"] = "local2" } }; + var localArtists = new List { new Dictionary { ["id"] = "local3", ["name"] = "Local" } }; + var externalResult = new SearchResult + { + Songs = new List(), + Albums = new List(), + Artists = new List() + }; + + // Act + var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults( + localSongs, localAlbums, localArtists, externalResult, true); + + // Assert + Assert.Single(mergedSongs); + Assert.Single(mergedAlbums); + Assert.Single(mergedArtists); + } +} diff --git a/octo-fiesta.Tests/SubsonicProxyServiceTests.cs b/octo-fiesta.Tests/SubsonicProxyServiceTests.cs new file mode 100644 index 0000000..f5b40e1 --- /dev/null +++ b/octo-fiesta.Tests/SubsonicProxyServiceTests.cs @@ -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 _mockHttpMessageHandler; + private readonly Mock _mockHttpClientFactory; + + public SubsonicProxyServiceTests() + { + _mockHttpMessageHandler = new Mock(); + var httpClient = new HttpClient(_mockHttpMessageHandler.Object); + + _mockHttpClientFactory = new Mock(); + _mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny())).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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + var parameters = new Dictionary + { + { "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()) + }; + + _mockHttpMessageHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(responseMessage); + + var parameters = new Dictionary + { + { "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()) + }; + + _mockHttpMessageHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(responseMessage); + + var parameters = new Dictionary + { + { "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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + var parameters = new Dictionary { { "u", "admin" } }; + + // Act & Assert + await Assert.ThrowsAsync(() => + _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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + var parameters = new Dictionary { { "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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + var parameters = new Dictionary { { "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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Network error")); + + var parameters = new Dictionary { { "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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + var parameters = new Dictionary + { + { "id", "song123" }, + { "u", "admin" } + }; + + // Act + var result = await _service.RelayStreamAsync(parameters, CancellationToken.None); + + // Assert + var fileResult = Assert.IsType(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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + var parameters = new Dictionary { { "id", "song123" } }; + + // Act + var result = await _service.RelayStreamAsync(parameters, CancellationToken.None); + + // Assert + var statusResult = Assert.IsType(result); + Assert.Equal(404, statusResult.StatusCode); + } + + [Fact] + public async Task RelayStreamAsync_Exception_ReturnsObjectResultWith500() + { + // Arrange + _mockHttpMessageHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Connection failed")); + + var parameters = new Dictionary { { "id", "song123" } }; + + // Act + var result = await _service.RelayStreamAsync(parameters, CancellationToken.None); + + // Assert + var objectResult = Assert.IsType(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>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + var parameters = new Dictionary { { "id", "song123" } }; + + // Act + var result = await _service.RelayStreamAsync(parameters, CancellationToken.None); + + // Assert + var fileResult = Assert.IsType(result); + Assert.Equal("audio/mpeg", fileResult.ContentType); + } +} diff --git a/octo-fiesta.Tests/SubsonicRequestParserTests.cs b/octo-fiesta.Tests/SubsonicRequestParserTests.cs new file mode 100644 index 0000000..3e616a6 --- /dev/null +++ b/octo-fiesta.Tests/SubsonicRequestParserTests.cs @@ -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"]); + } +} diff --git a/octo-fiesta.Tests/SubsonicResponseBuilderTests.cs b/octo-fiesta.Tests/SubsonicResponseBuilderTests.cs new file mode 100644 index 0000000..5581150 --- /dev/null +++ b/octo-fiesta.Tests/SubsonicResponseBuilderTests.cs @@ -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(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(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(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(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(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(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 + { + 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(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 + { + 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(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 + { + 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(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 + { + 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(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(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() + }; + + // Act + var result = _builder.CreateAlbumResponse("json", album); + + // Assert + var jsonResult = Assert.IsType(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()); + } +} diff --git a/octo-fiesta/Controllers/SubSonicController.cs b/octo-fiesta/Controllers/SubSonicController.cs index af7156c..0ad27a0 100644 --- a/octo-fiesta/Controllers/SubSonicController.cs +++ b/octo-fiesta/Controllers/SubSonicController.cs @@ -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 _logger; public SubsonicController( - IHttpClientFactory httpClientFactory, IOptions subsonicSettings, IMusicMetadataService metadataService, ILocalLibraryService localLibraryService, IDownloadService downloadService, + SubsonicRequestParser requestParser, + SubsonicResponseBuilder responseBuilder, + SubsonicModelMapper modelMapper, + SubsonicProxyService proxyService, ILogger 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> ExtractAllParameters() { - var parameters = new Dictionary(); - - // 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>(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 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); - } - /// /// Merges local and external search results. /// @@ -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); } /// @@ -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 parameters) - { - try - { - var result = await RelayToSubsonic(endpoint, parameters); - return ((byte[])result.Body, result.ContentType, true); - } - catch - { - return (null, null, false); - } - } - - private async Task RelayStreamToSubsonic(Dictionary 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(); - var localAlbums = new List(); - var localArtists = new List(); + var (localSongs, localAlbums, localArtists) = subsonicResult.Success && subsonicResult.Body != null + ? _modelMapper.ParseSearchResponse(subsonicResult.Body, subsonicResult.ContentType) + : (new List(), new List(), new List()); - 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(StringComparer.OrdinalIgnoreCase); - foreach (var artist in localArtists) - { - if (artist is Dictionary 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(StringComparer.OrdinalIgnoreCase); - foreach (var artist in localArtists.Cast()) + foreach (var artist in mergedArtists.Cast()) { - 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()) { - // 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()) - { - album.Name = ns + "album"; searchResult3.Add(album); } - foreach (var album in externalResult.Albums) + foreach (var song in mergedSongs.Cast()) { - searchResult3.Add(ConvertAlbumToSubsonicXml(album, ns)); - } - - foreach (var song in localSongs.Cast()) - { - 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(); - 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 ConvertSongToSubsonicJson(Song song) - { - var result = new Dictionary - { - ["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()) - ); - } - - /// - /// Creates a JSON Subsonic response with "subsonic-response" key (with hyphen). - /// - private IActionResult CreateSubsonicJsonResponse(object responseContent) - { - var response = new Dictionary - { - ["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 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}"); } } } \ No newline at end of file diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index 9a5bc51..ddd47ff 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -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("Subsonic:MusicS // Registered as Singleton to share state (mappings cache, scan debounce, download tracking, rate limiting) builder.Services.AddSingleton(); +// Subsonic services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + // Register music service based on configuration if (musicService == MusicService.Qobuz) { diff --git a/octo-fiesta/Services/Subsonic/SubsonicModelMapper.cs b/octo-fiesta/Services/Subsonic/SubsonicModelMapper.cs new file mode 100644 index 0000000..79cc7f3 --- /dev/null +++ b/octo-fiesta/Services/Subsonic/SubsonicModelMapper.cs @@ -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; + +/// +/// Handles parsing Subsonic API responses and merging local with external search results. +/// +public class SubsonicModelMapper +{ + private readonly SubsonicResponseBuilder _responseBuilder; + private readonly ILogger _logger; + + public SubsonicModelMapper( + SubsonicResponseBuilder responseBuilder, + ILogger logger) + { + _responseBuilder = responseBuilder; + _logger = logger; + } + + /// + /// Parses a Subsonic search response and extracts songs, albums, and artists. + /// + public (List Songs, List Albums, List Artists) ParseSearchResponse( + byte[] responseBody, + string? contentType) + { + var songs = new List(); + var albums = new List(); + var artists = new List(); + + 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); + } + + /// + /// Merges local search results with external search results, deduplicating by name. + /// + public (List MergedSongs, List MergedAlbums, List MergedArtists) MergeSearchResults( + List localSongs, + List localAlbums, + List localArtists, + SearchResult externalResult, + bool isJson) + { + if (isJson) + { + return MergeSearchResultsJson(localSongs, localAlbums, localArtists, externalResult); + } + else + { + return MergeSearchResultsXml(localSongs, localAlbums, localArtists, externalResult); + } + } + + private (List MergedSongs, List MergedAlbums, List MergedArtists) MergeSearchResultsJson( + List localSongs, + List localAlbums, + List 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(StringComparer.OrdinalIgnoreCase); + foreach (var artist in localArtists) + { + if (artist is Dictionary 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 MergedSongs, List MergedAlbums, List MergedArtists) MergeSearchResultsXml( + List localSongs, + List localAlbums, + List 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(StringComparer.OrdinalIgnoreCase); + var mergedArtists = new List(); + + foreach (var artist in localArtists.Cast()) + { + 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(); + foreach (var album in localAlbums.Cast()) + { + album.Name = ns + "album"; + mergedAlbums.Add(album); + } + foreach (var album in externalResult.Albums) + { + mergedAlbums.Add(_responseBuilder.ConvertAlbumToXml(album, ns)); + } + + // Songs + var mergedSongs = new List(); + foreach (var song in localSongs.Cast()) + { + song.Name = ns + "song"; + mergedSongs.Add(song); + } + foreach (var song in externalResult.Songs) + { + mergedSongs.Add(_responseBuilder.ConvertSongToXml(song, ns)); + } + + return (mergedSongs, mergedAlbums, mergedArtists); + } +} diff --git a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs new file mode 100644 index 0000000..ff531f2 --- /dev/null +++ b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Mvc; +using octo_fiesta.Models.Settings; + +namespace octo_fiesta.Services.Subsonic; + +/// +/// Handles proxying requests to the underlying Subsonic server. +/// +public class SubsonicProxyService +{ + private readonly HttpClient _httpClient; + private readonly SubsonicSettings _subsonicSettings; + + public SubsonicProxyService( + IHttpClientFactory httpClientFactory, + Microsoft.Extensions.Options.IOptions subsonicSettings) + { + _httpClient = httpClientFactory.CreateClient(); + _subsonicSettings = subsonicSettings.Value; + } + + /// + /// Relays a request to the Subsonic server and returns the response. + /// + public async Task<(byte[] Body, string? ContentType)> RelayAsync( + string endpoint, + Dictionary 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); + } + + /// + /// Safely relays a request to the Subsonic server, returning null on failure. + /// + public async Task<(byte[]? Body, string? ContentType, bool Success)> RelaySafeAsync( + string endpoint, + Dictionary parameters) + { + try + { + var result = await RelayAsync(endpoint, parameters); + return (result.Body, result.ContentType, true); + } + catch + { + return (null, null, false); + } + } + + /// + /// Relays a stream request to the Subsonic server with range processing support. + /// + public async Task RelayStreamAsync( + Dictionary 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 + }; + } + } +} diff --git a/octo-fiesta/Services/Subsonic/SubsonicRequestParser.cs b/octo-fiesta/Services/Subsonic/SubsonicRequestParser.cs new file mode 100644 index 0000000..9aba076 --- /dev/null +++ b/octo-fiesta/Services/Subsonic/SubsonicRequestParser.cs @@ -0,0 +1,105 @@ +using Microsoft.AspNetCore.WebUtilities; +using System.Text.Json; + +namespace octo_fiesta.Services.Subsonic; + +/// +/// Service responsible for parsing HTTP request parameters from various sources +/// (query string, form body, JSON body) for Subsonic API requests. +/// +public class SubsonicRequestParser +{ + /// + /// Extracts all parameters from an HTTP request (query parameters + body parameters). + /// Supports multiple content types: application/x-www-form-urlencoded and application/json. + /// + /// The HTTP request to parse + /// Dictionary containing all extracted parameters + public async Task> ExtractAllParametersAsync(HttpRequest request) + { + var parameters = new Dictionary(); + + // 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; + } + + /// + /// Extracts parameters from form-encoded request body. + /// + private async Task ExtractFormParametersAsync(HttpRequest request, Dictionary 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(); + } + } + } + } + + /// + /// Extracts parameters from JSON request body. + /// + private async Task ExtractJsonParametersAsync(HttpRequest request, Dictionary parameters) + { + using var reader = new StreamReader(request.Body); + var body = await reader.ReadToEndAsync(); + + if (!string.IsNullOrEmpty(body)) + { + try + { + var bodyParams = JsonSerializer.Deserialize>(body); + if (bodyParams != null) + { + foreach (var param in bodyParams) + { + parameters[param.Key] = param.Value?.ToString() ?? ""; + } + } + } + catch (JsonException) + { + // Ignore JSON parsing errors + } + } + } +} diff --git a/octo-fiesta/Services/Subsonic/SubsonicResponseBuilder.cs b/octo-fiesta/Services/Subsonic/SubsonicResponseBuilder.cs new file mode 100644 index 0000000..0ad7cbd --- /dev/null +++ b/octo-fiesta/Services/Subsonic/SubsonicResponseBuilder.cs @@ -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; + +/// +/// Handles building Subsonic API responses in both XML and JSON formats. +/// +public class SubsonicResponseBuilder +{ + private const string SubsonicNamespace = "http://subsonic.org/restapi"; + private const string SubsonicVersion = "1.16.1"; + + /// + /// Creates a generic Subsonic response with status "ok". + /// + 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" }; + } + + /// + /// Creates a Subsonic error response. + /// + 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" }; + } + + /// + /// Creates a Subsonic response containing a single song. + /// + 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" }; + } + + /// + /// Creates a Subsonic response containing an album with songs. + /// + 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" }; + } + + /// + /// Creates a Subsonic response containing an artist with albums. + /// + public IActionResult CreateArtistResponse(string format, Artist artist, List 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" }; + } + + /// + /// Creates a JSON Subsonic response with "subsonic-response" key (with hyphen). + /// + public IActionResult CreateJsonResponse(object responseContent) + { + var response = new Dictionary + { + ["subsonic-response"] = responseContent + }; + return new JsonResult(response); + } + + /// + /// Converts a Song domain model to Subsonic JSON format. + /// + public Dictionary ConvertSongToJson(Song song) + { + var result = new Dictionary + { + ["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; + } + + /// + /// Converts an Album domain model to Subsonic JSON format. + /// + 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 + }; + } + + /// + /// Converts an Artist domain model to Subsonic JSON format. + /// + public object ConvertArtistToJson(Artist artist) + { + return new + { + id = artist.Id, + name = artist.Name, + albumCount = artist.AlbumCount ?? 0, + coverArt = artist.Id, + isExternal = !artist.IsLocal + }; + } + + /// + /// Converts a Song domain model to Subsonic XML format. + /// + 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()) + ); + } + + /// + /// Converts an Album domain model to Subsonic XML format. + /// + 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()) + ); + } + + /// + /// Converts an Artist domain model to Subsonic XML format. + /// + 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()) + ); + } + + /// + /// Converts a Subsonic JSON element to a dictionary. + /// + public object ConvertSubsonicJsonElement(JsonElement element, bool isLocal) + { + var dict = new Dictionary(); + foreach (var prop in element.EnumerateObject()) + { + dict[prop.Name] = ConvertJsonValue(prop.Value); + } + dict["isExternal"] = !isLocal; + return dict; + } + + /// + /// Converts a Subsonic XML element. + /// + 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() + }; + } +}