refactor: extract subsonic controller logic into specialized services

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

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

View File

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

View File

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

View File

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

View File

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