using System.Text; using System.Text.Json; using System.Xml.Linq; using octo_fiesta.Models.Search; using octo_fiesta.Models.Subsonic; namespace octo_fiesta.Services.Subsonic; /// /// Handles parsing Subsonic API responses and merging local with external search results. /// public class SubsonicModelMapper { private readonly SubsonicResponseBuilder _responseBuilder; private readonly ILogger _logger; public SubsonicModelMapper( SubsonicResponseBuilder responseBuilder, ILogger logger) { _responseBuilder = responseBuilder; _logger = logger; } /// /// Parses a Subsonic search response and extracts songs, albums, and artists. /// public (List Songs, List Albums, List Artists) ParseSearchResponse( byte[] responseBody, string? contentType) { var songs = new List(); var albums = new List(); var artists = new List(); try { var content = Encoding.UTF8.GetString(responseBody); if (contentType?.Contains("json") == true) { var jsonDoc = JsonDocument.Parse(content); if (jsonDoc.RootElement.TryGetProperty("subsonic-response", out var response) && response.TryGetProperty("searchResult3", out var searchResult)) { if (searchResult.TryGetProperty("song", out var songElements)) { foreach (var song in songElements.EnumerateArray()) { songs.Add(_responseBuilder.ConvertSubsonicJsonElement(song, true)); } } if (searchResult.TryGetProperty("album", out var albumElements)) { foreach (var album in albumElements.EnumerateArray()) { albums.Add(_responseBuilder.ConvertSubsonicJsonElement(album, true)); } } if (searchResult.TryGetProperty("artist", out var artistElements)) { foreach (var artist in artistElements.EnumerateArray()) { artists.Add(_responseBuilder.ConvertSubsonicJsonElement(artist, true)); } } } } else { var xmlDoc = XDocument.Parse(content); var ns = xmlDoc.Root?.GetDefaultNamespace() ?? XNamespace.None; var searchResult = xmlDoc.Descendants(ns + "searchResult3").FirstOrDefault(); if (searchResult != null) { foreach (var song in searchResult.Elements(ns + "song")) { songs.Add(_responseBuilder.ConvertSubsonicXmlElement(song, "song")); } foreach (var album in searchResult.Elements(ns + "album")) { albums.Add(_responseBuilder.ConvertSubsonicXmlElement(album, "album")); } foreach (var artist in searchResult.Elements(ns + "artist")) { artists.Add(_responseBuilder.ConvertSubsonicXmlElement(artist, "artist")); } } } } catch (Exception ex) { _logger.LogWarning(ex, "Error parsing Subsonic search response"); } return (songs, albums, artists); } /// /// Merges local and external search results (songs, albums, artists, playlists). /// public (List MergedSongs, List MergedAlbums, List MergedArtists) MergeSearchResults( List localSongs, List localAlbums, List localArtists, SearchResult externalResult, List externalPlaylists, bool isJson) { if (isJson) { return MergeSearchResultsJson(localSongs, localAlbums, localArtists, externalResult, externalPlaylists); } else { return MergeSearchResultsXml(localSongs, localAlbums, localArtists, externalResult, externalPlaylists); } } private (List MergedSongs, List MergedAlbums, List MergedArtists) MergeSearchResultsJson( List localSongs, List localAlbums, List localArtists, SearchResult externalResult, List externalPlaylists) { var mergedSongs = localSongs .Concat(externalResult.Songs.Select(s => _responseBuilder.ConvertSongToJson(s))) .ToList(); // Merge albums with playlists (playlists appear as albums with genre "Playlist") var mergedAlbums = localAlbums .Concat(externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJson(a))) .Concat(externalPlaylists.Select(p => ConvertPlaylistToAlbumJson(p))) .ToList(); // Deduplicate artists by name - prefer local artists over external ones var localArtistNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var artist in localArtists) { if (artist is Dictionary dict && dict.TryGetValue("name", out var nameObj)) { localArtistNames.Add(nameObj?.ToString() ?? ""); } } var mergedArtists = localArtists.ToList(); foreach (var externalArtist in externalResult.Artists) { // Only add external artist if no local artist with same name exists if (!localArtistNames.Contains(externalArtist.Name)) { mergedArtists.Add(_responseBuilder.ConvertArtistToJson(externalArtist)); } } return (mergedSongs, mergedAlbums, mergedArtists); } private (List MergedSongs, List MergedAlbums, List MergedArtists) MergeSearchResultsXml( List localSongs, List localAlbums, List localArtists, SearchResult externalResult, List externalPlaylists) { var ns = XNamespace.Get("http://subsonic.org/restapi"); // Deduplicate artists by name - prefer local artists over external ones var localArtistNamesXml = new HashSet(StringComparer.OrdinalIgnoreCase); var mergedArtists = new List(); foreach (var artist in localArtists.Cast()) { var name = artist.Attribute("name")?.Value; if (!string.IsNullOrEmpty(name)) { localArtistNamesXml.Add(name); } artist.Name = ns + "artist"; mergedArtists.Add(artist); } foreach (var artist in externalResult.Artists) { // Only add external artist if no local artist with same name exists if (!localArtistNamesXml.Contains(artist.Name)) { mergedArtists.Add(_responseBuilder.ConvertArtistToXml(artist, ns)); } } // Albums var mergedAlbums = new List(); foreach (var album in localAlbums.Cast()) { album.Name = ns + "album"; mergedAlbums.Add(album); } foreach (var album in externalResult.Albums) { mergedAlbums.Add(_responseBuilder.ConvertAlbumToXml(album, ns)); } // Add playlists as albums foreach (var playlist in externalPlaylists) { mergedAlbums.Add(ConvertPlaylistToAlbumXml(playlist, ns)); } // Songs var mergedSongs = new List(); foreach (var song in localSongs.Cast()) { song.Name = ns + "song"; mergedSongs.Add(song); } foreach (var song in externalResult.Songs) { mergedSongs.Add(_responseBuilder.ConvertSongToXml(song, ns)); } return (mergedSongs, mergedAlbums, mergedArtists); } /// /// Converts an ExternalPlaylist to a JSON object representing an album. /// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}". /// private Dictionary ConvertPlaylistToAlbumJson(ExternalPlaylist playlist) { var artistName = $"🎵 {char.ToUpper(playlist.Provider[0])}{playlist.Provider.Substring(1)}"; if (!string.IsNullOrEmpty(playlist.CuratorName)) { artistName += $" {playlist.CuratorName}"; } var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}"; var album = new Dictionary { ["id"] = playlist.Id, ["name"] = playlist.Name, ["artist"] = artistName, ["artistId"] = artistId, ["genre"] = "Playlist", ["songCount"] = playlist.TrackCount, ["duration"] = playlist.Duration }; if (playlist.CreatedDate.HasValue) { album["year"] = playlist.CreatedDate.Value.Year; album["created"] = playlist.CreatedDate.Value.ToString("yyyy-MM-ddTHH:mm:ss"); } if (!string.IsNullOrEmpty(playlist.CoverUrl)) { album["coverArt"] = playlist.Id; } return album; } /// /// Converts an ExternalPlaylist to an XML element representing an album. /// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}". /// private XElement ConvertPlaylistToAlbumXml(ExternalPlaylist playlist, XNamespace ns) { var artistName = $"🎵 {char.ToUpper(playlist.Provider[0])}{playlist.Provider.Substring(1)}"; if (!string.IsNullOrEmpty(playlist.CuratorName)) { artistName += $" {playlist.CuratorName}"; } var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}"; var album = new XElement(ns + "album", new XAttribute("id", playlist.Id), new XAttribute("name", playlist.Name), new XAttribute("artist", artistName), new XAttribute("artistId", artistId), new XAttribute("genre", "Playlist"), new XAttribute("songCount", playlist.TrackCount), new XAttribute("duration", playlist.Duration) ); if (playlist.CreatedDate.HasValue) { album.Add(new XAttribute("year", playlist.CreatedDate.Value.Year)); album.Add(new XAttribute("created", playlist.CreatedDate.Value.ToString("yyyy-MM-ddTHH:mm:ss"))); } if (!string.IsNullOrEmpty(playlist.CoverUrl)) { album.Add(new XAttribute("coverArt", playlist.Id)); } return album; } }