mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -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:
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