mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
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:
347
octo-fiesta.Tests/SubsonicModelMapperTests.cs
Normal file
347
octo-fiesta.Tests/SubsonicModelMapperTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
325
octo-fiesta.Tests/SubsonicProxyServiceTests.cs
Normal file
325
octo-fiesta.Tests/SubsonicProxyServiceTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
octo-fiesta.Tests/SubsonicRequestParserTests.cs
Normal file
202
octo-fiesta.Tests/SubsonicRequestParserTests.cs
Normal 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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
322
octo-fiesta.Tests/SubsonicResponseBuilderTests.cs
Normal file
322
octo-fiesta.Tests/SubsonicResponseBuilderTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ using octo_fiesta.Models.Search;
|
|||||||
using octo_fiesta.Models.Subsonic;
|
using octo_fiesta.Models.Subsonic;
|
||||||
using octo_fiesta.Services;
|
using octo_fiesta.Services;
|
||||||
using octo_fiesta.Services.Local;
|
using octo_fiesta.Services.Local;
|
||||||
|
using octo_fiesta.Services.Subsonic;
|
||||||
|
|
||||||
namespace octo_fiesta.Controllers;
|
namespace octo_fiesta.Controllers;
|
||||||
|
|
||||||
@@ -17,26 +18,35 @@ namespace octo_fiesta.Controllers;
|
|||||||
[Route("")]
|
[Route("")]
|
||||||
public class SubsonicController : ControllerBase
|
public class SubsonicController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly SubsonicSettings _subsonicSettings;
|
private readonly SubsonicSettings _subsonicSettings;
|
||||||
private readonly IMusicMetadataService _metadataService;
|
private readonly IMusicMetadataService _metadataService;
|
||||||
private readonly ILocalLibraryService _localLibraryService;
|
private readonly ILocalLibraryService _localLibraryService;
|
||||||
private readonly IDownloadService _downloadService;
|
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;
|
private readonly ILogger<SubsonicController> _logger;
|
||||||
|
|
||||||
public SubsonicController(
|
public SubsonicController(
|
||||||
IHttpClientFactory httpClientFactory,
|
|
||||||
IOptions<SubsonicSettings> subsonicSettings,
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
ILocalLibraryService localLibraryService,
|
ILocalLibraryService localLibraryService,
|
||||||
IDownloadService downloadService,
|
IDownloadService downloadService,
|
||||||
|
SubsonicRequestParser requestParser,
|
||||||
|
SubsonicResponseBuilder responseBuilder,
|
||||||
|
SubsonicModelMapper modelMapper,
|
||||||
|
SubsonicProxyService proxyService,
|
||||||
ILogger<SubsonicController> logger)
|
ILogger<SubsonicController> logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
|
||||||
_subsonicSettings = subsonicSettings.Value;
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
_metadataService = metadataService;
|
_metadataService = metadataService;
|
||||||
_localLibraryService = localLibraryService;
|
_localLibraryService = localLibraryService;
|
||||||
_downloadService = downloadService;
|
_downloadService = downloadService;
|
||||||
|
_requestParser = requestParser;
|
||||||
|
_responseBuilder = responseBuilder;
|
||||||
|
_modelMapper = modelMapper;
|
||||||
|
_proxyService = proxyService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
|
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
|
||||||
@@ -44,91 +54,13 @@ public class SubsonicController : ControllerBase
|
|||||||
throw new Exception("Error: Environment variable SUBSONIC_URL is not set.");
|
throw new Exception("Error: Environment variable SUBSONIC_URL is not set.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract all parameters (query + body)
|
// Extract all parameters (query + body)
|
||||||
private async Task<Dictionary<string, string>> ExtractAllParameters()
|
private async Task<Dictionary<string, string>> ExtractAllParameters()
|
||||||
{
|
{
|
||||||
var parameters = new Dictionary<string, string>();
|
return await _requestParser.ExtractAllParametersAsync(Request);
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
/// <summary>
|
||||||
/// Merges local and external search results.
|
/// Merges local and external search results.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -147,17 +79,17 @@ public class SubsonicController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await RelayToSubsonic("rest/search3", parameters);
|
var result = await _proxyService.RelayAsync("rest/search3", parameters);
|
||||||
var contentType = result.ContentType ?? $"application/{format}";
|
var contentType = result.ContentType ?? $"application/{format}";
|
||||||
return File((byte[])result.Body, contentType);
|
return File(result.Body, contentType);
|
||||||
}
|
}
|
||||||
catch
|
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(
|
var externalTask = _metadataService.SearchAllAsync(
|
||||||
cleanQuery,
|
cleanQuery,
|
||||||
int.TryParse(parameters.GetValueOrDefault("songCount", "20"), out var sc) ? sc : 20,
|
int.TryParse(parameters.GetValueOrDefault("songCount", "20"), out var sc) ? sc : 20,
|
||||||
@@ -193,7 +125,7 @@ public class SubsonicController : ControllerBase
|
|||||||
|
|
||||||
if (!isExternal)
|
if (!isExternal)
|
||||||
{
|
{
|
||||||
return await RelayStreamToSubsonic(parameters);
|
return await _proxyService.RelayStreamAsync(parameters, HttpContext.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider!, externalId!);
|
var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider!, externalId!);
|
||||||
@@ -229,26 +161,26 @@ public class SubsonicController : ControllerBase
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(id))
|
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);
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id);
|
||||||
|
|
||||||
if (!isExternal)
|
if (!isExternal)
|
||||||
{
|
{
|
||||||
var result = await RelayToSubsonic("rest/getSong", parameters);
|
var result = await _proxyService.RelayAsync("rest/getSong", parameters);
|
||||||
var contentType = result.ContentType ?? $"application/{format}";
|
var contentType = result.ContentType ?? $"application/{format}";
|
||||||
return File((byte[])result.Body, contentType);
|
return File(result.Body, contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||||
|
|
||||||
if (song == null)
|
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>
|
/// <summary>
|
||||||
@@ -265,7 +197,7 @@ public class SubsonicController : ControllerBase
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(id))
|
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);
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id);
|
||||||
@@ -275,7 +207,7 @@ public class SubsonicController : ControllerBase
|
|||||||
var artist = await _metadataService.GetArtistAsync(provider!, externalId!);
|
var artist = await _metadataService.GetArtistAsync(provider!, externalId!);
|
||||||
if (artist == null)
|
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!);
|
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)
|
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);
|
var navidromeContent = Encoding.UTF8.GetString(navidromeResult.Body);
|
||||||
@@ -316,13 +248,13 @@ public class SubsonicController : ControllerBase
|
|||||||
response.TryGetProperty("artist", out var artistElement))
|
response.TryGetProperty("artist", out var artistElement))
|
||||||
{
|
{
|
||||||
artistName = artistElement.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "";
|
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))
|
if (artistElement.TryGetProperty("album", out var albums))
|
||||||
{
|
{
|
||||||
foreach (var album in albums.EnumerateArray())
|
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))
|
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;
|
artistDict["albumCount"] = mergedAlbums.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
return CreateSubsonicJsonResponse(new
|
return _responseBuilder.CreateJsonResponse(new
|
||||||
{
|
{
|
||||||
status = "ok",
|
status = "ok",
|
||||||
version = "1.16.1",
|
version = "1.16.1",
|
||||||
@@ -405,7 +337,7 @@ public class SubsonicController : ControllerBase
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(id))
|
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);
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id);
|
||||||
@@ -416,17 +348,17 @@ public class SubsonicController : ControllerBase
|
|||||||
|
|
||||||
if (album == null)
|
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)
|
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);
|
var navidromeContent = Encoding.UTF8.GetString(navidromeResult.Body);
|
||||||
@@ -443,13 +375,13 @@ public class SubsonicController : ControllerBase
|
|||||||
{
|
{
|
||||||
albumName = albumElement.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "";
|
albumName = albumElement.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "";
|
||||||
artistName = albumElement.TryGetProperty("artist", out var artist) ? artist.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))
|
if (albumElement.TryGetProperty("song", out var songs))
|
||||||
{
|
{
|
||||||
foreach (var song in songs.EnumerateArray())
|
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))
|
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",
|
status = "ok",
|
||||||
version = "1.16.1",
|
version = "1.16.1",
|
||||||
@@ -566,9 +498,9 @@ public class SubsonicController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await RelayToSubsonic("rest/getCoverArt", parameters);
|
var result = await _proxyService.RelayAsync("rest/getCoverArt", parameters);
|
||||||
var contentType = result.ContentType ?? "image/jpeg";
|
var contentType = result.ContentType ?? "image/jpeg";
|
||||||
return File((byte[])result.Body, contentType);
|
return File(result.Body, contentType);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -619,7 +551,8 @@ public class SubsonicController : ControllerBase
|
|||||||
|
|
||||||
if (coverUrl != null)
|
if (coverUrl != null)
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(coverUrl);
|
using var httpClient = new HttpClient();
|
||||||
|
var response = await httpClient.GetAsync(coverUrl);
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
@@ -633,148 +566,26 @@ public class SubsonicController : ControllerBase
|
|||||||
|
|
||||||
#region Helper Methods
|
#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(
|
private IActionResult MergeSearchResults(
|
||||||
(byte[]? Body, string? ContentType, bool Success) subsonicResult,
|
(byte[]? Body, string? ContentType, bool Success) subsonicResult,
|
||||||
SearchResult externalResult,
|
SearchResult externalResult,
|
||||||
string format)
|
string format)
|
||||||
{
|
{
|
||||||
var localSongs = new List<object>();
|
var (localSongs, localAlbums, localArtists) = subsonicResult.Success && subsonicResult.Body != null
|
||||||
var localAlbums = new List<object>();
|
? _modelMapper.ParseSearchResponse(subsonicResult.Body, subsonicResult.ContentType)
|
||||||
var localArtists = new List<object>();
|
: (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
|
return _responseBuilder.CreateJsonResponse(new
|
||||||
{
|
|
||||||
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
|
|
||||||
{
|
{
|
||||||
status = "ok",
|
status = "ok",
|
||||||
version = "1.16.1",
|
version = "1.16.1",
|
||||||
@@ -789,49 +600,20 @@ public class SubsonicController : ControllerBase
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var ns = XNamespace.Get("http://subsonic.org/restapi");
|
var ns = XNamespace.Get("http://subsonic.org/restapi");
|
||||||
|
|
||||||
var searchResult3 = new XElement(ns + "searchResult3");
|
var searchResult3 = new XElement(ns + "searchResult3");
|
||||||
|
|
||||||
// Deduplicate artists by name - prefer local artists over external ones
|
foreach (var artist in mergedArtists.Cast<XElement>())
|
||||||
var localArtistNamesXml = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (var artist in localArtists.Cast<XElement>())
|
|
||||||
{
|
{
|
||||||
var name = artist.Attribute("name")?.Value;
|
|
||||||
if (!string.IsNullOrEmpty(name))
|
|
||||||
{
|
|
||||||
localArtistNamesXml.Add(name);
|
|
||||||
}
|
|
||||||
artist.Name = ns + "artist";
|
|
||||||
searchResult3.Add(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);
|
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);
|
searchResult3.Add(song);
|
||||||
}
|
}
|
||||||
foreach (var song in externalResult.Songs)
|
|
||||||
{
|
|
||||||
searchResult3.Add(ConvertSongToSubsonicXml(song, ns));
|
|
||||||
}
|
|
||||||
|
|
||||||
var doc = new XDocument(
|
var doc = new XDocument(
|
||||||
new XElement(ns + "subsonic-response",
|
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)
|
private string GetContentType(string filePath)
|
||||||
{
|
{
|
||||||
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
@@ -1162,14 +654,14 @@ public class SubsonicController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await RelayToSubsonic(endpoint, parameters);
|
var result = await _proxyService.RelayAsync(endpoint, parameters);
|
||||||
var contentType = result.ContentType ?? $"application/{format}";
|
var contentType = result.ContentType ?? $"application/{format}";
|
||||||
return File((byte[])result.Body, contentType);
|
return File(result.Body, contentType);
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
// Return Subsonic-compatible error response
|
// 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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ using octo_fiesta.Services.Deezer;
|
|||||||
using octo_fiesta.Services.Qobuz;
|
using octo_fiesta.Services.Qobuz;
|
||||||
using octo_fiesta.Services.Local;
|
using octo_fiesta.Services.Local;
|
||||||
using octo_fiesta.Services.Validation;
|
using octo_fiesta.Services.Validation;
|
||||||
|
using octo_fiesta.Services.Subsonic;
|
||||||
using octo_fiesta.Middleware;
|
using octo_fiesta.Middleware;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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)
|
// Registered as Singleton to share state (mappings cache, scan debounce, download tracking, rate limiting)
|
||||||
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
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
|
// Register music service based on configuration
|
||||||
if (musicService == MusicService.Qobuz)
|
if (musicService == MusicService.Qobuz)
|
||||||
{
|
{
|
||||||
|
|||||||
214
octo-fiesta/Services/Subsonic/SubsonicModelMapper.cs
Normal file
214
octo-fiesta/Services/Subsonic/SubsonicModelMapper.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
octo-fiesta/Services/Subsonic/SubsonicProxyService.cs
Normal file
100
octo-fiesta/Services/Subsonic/SubsonicProxyService.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
octo-fiesta/Services/Subsonic/SubsonicRequestParser.cs
Normal file
105
octo-fiesta/Services/Subsonic/SubsonicRequestParser.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
343
octo-fiesta/Services/Subsonic/SubsonicResponseBuilder.cs
Normal file
343
octo-fiesta/Services/Subsonic/SubsonicResponseBuilder.cs
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user