From 4dd730d1b95f947d7656f4d2455f55f5a1791767 Mon Sep 17 00:00:00 2001 From: oca-agent <277152249+oca-agent@users.noreply.github.com> Date: Tue, 5 May 2026 22:45:36 -0400 Subject: [PATCH 1/3] feat: implement soundcloud integration --- .../SoundCloud/SoundCloudDiscoveryTest.cs | 28 +++ .../SoundCloud/SoundCloudMappingTest.cs | 34 ++++ .../SoundCloudStreamingClientServiceTest.cs | 86 ++++++++ smoc/Configuration/SoundCloudConfig.cs | 20 ++ smoc/Configuration/StreamingService.cs | 9 +- smoc/Program.cs | 5 + .../SoundCloud/Models/SoundCloudPlaylist.cs | 9 + .../Models/SoundCloudSearchResponse.cs | 8 + .../Models/SoundCloudStreamResponse.cs | 7 + .../SoundCloud/Models/SoundCloudTrack.cs | 27 +++ .../SoundCloud/Models/SoundCloudUser.cs | 9 + .../SoundCloud/SoundCloudStreamingClient.cs | 192 ++++++++++++++++++ .../SoundCloud/Util/SoundCloudDiscovery.cs | 17 ++ .../SoundCloud/Util/SoundCloudMapper.cs | 13 ++ 14 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs create mode 100644 smoc.Tests/Streaming/SoundCloud/SoundCloudMappingTest.cs create mode 100644 smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs create mode 100644 smoc/Configuration/SoundCloudConfig.cs create mode 100644 smoc/Streaming/SoundCloud/Models/SoundCloudPlaylist.cs create mode 100644 smoc/Streaming/SoundCloud/Models/SoundCloudSearchResponse.cs create mode 100644 smoc/Streaming/SoundCloud/Models/SoundCloudStreamResponse.cs create mode 100644 smoc/Streaming/SoundCloud/Models/SoundCloudTrack.cs create mode 100644 smoc/Streaming/SoundCloud/Models/SoundCloudUser.cs create mode 100644 smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs create mode 100644 smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs create mode 100644 smoc/Streaming/SoundCloud/Util/SoundCloudMapper.cs diff --git a/smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs b/smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs new file mode 100644 index 0000000..d3822a6 --- /dev/null +++ b/smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs @@ -0,0 +1,28 @@ +using Smoc.Streaming.SoundCloud.Util; + +namespace smoc.Tests.Streaming.SoundCloud; + +public class SoundCloudDiscoveryTest { + [Fact] + public void ExtractScriptUrls_FindsScripts() { + var html = ""; + var urls = SoundCloudDiscovery.ExtractScriptUrls(html).ToList(); + Assert.Equal(2, urls.Count); + Assert.Equal("https://a-v2.sndcdn.com/assets/1.js", urls[0]); + Assert.Equal("https://a-v2.sndcdn.com/assets/2.js", urls[1]); + } + + [Fact] + public void ExtractClientId_FindsId() { + var content = "window.Snd={}; Snd.config={client_id:\"0123456789abcdef0123456789abcdef\", ...}"; + var id = SoundCloudDiscovery.ExtractClientId(content); + Assert.Equal("0123456789abcdef0123456789abcdef", id); + } + + [Fact] + public void ExtractClientId_NotFound_ReturnsNull() { + var content = "console.log('hello');"; + var id = SoundCloudDiscovery.ExtractClientId(content); + Assert.Null(id); + } +} \ No newline at end of file diff --git a/smoc.Tests/Streaming/SoundCloud/SoundCloudMappingTest.cs b/smoc.Tests/Streaming/SoundCloud/SoundCloudMappingTest.cs new file mode 100644 index 0000000..d733767 --- /dev/null +++ b/smoc.Tests/Streaming/SoundCloud/SoundCloudMappingTest.cs @@ -0,0 +1,34 @@ +using Smoc.Streaming.SoundCloud.Models; +using Smoc.Streaming.SoundCloud.Util; + +namespace smoc.Tests.Streaming.SoundCloud; + +public class SoundCloudMappingTest { + [Fact] + public void MapTrackToSong_ReturnsCorrectSong() { + var user = new SoundCloudUser(123, "Test Artist", "http://avatar"); + var track = new SoundCloudTrack(456, "Test Track", 180000, "http://artwork-large.jpg", user, new SoundCloudMedia([])); + + var song = SoundCloudMapper.MapTrackToSong(track); + + Assert.Equal("456", song.Id); + Assert.Equal("Test Track", song.Title); + Assert.Equal(TimeSpan.FromSeconds(180), song.Duration); + Assert.Equal("Test Artist", song.Artist.Name); + Assert.Equal("123", song.Artist.Id); + Assert.Equal("SoundCloud Uploads", song.Album.Name); + Assert.Equal("sc-uploads-123", song.Album.Id); + Assert.Single(song.Album.Covers); + Assert.Equal("http://artwork-t500x500.jpg", song.Album.Covers.First().Url); + } + + [Fact] + public void MapTrackToSong_NoArtwork_ReturnsEmptyCovers() { + var user = new SoundCloudUser(123, "Test Artist", "http://avatar"); + var track = new SoundCloudTrack(456, "Test Track", 180000, null, user, new SoundCloudMedia([])); + + var song = SoundCloudMapper.MapTrackToSong(track); + + Assert.Empty(song.Album.Covers); + } +} \ No newline at end of file diff --git a/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs b/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs new file mode 100644 index 0000000..a0fef79 --- /dev/null +++ b/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs @@ -0,0 +1,86 @@ +using System.Net; +using System.Text.Json; +using Smoc.Streaming; +using Smoc.Streaming.SoundCloud; +using Smoc.Streaming.SoundCloud.Models; +using Smoc.Services.Caching; + +namespace smoc.Tests.Streaming.SoundCloud; + +public class SoundCloudStreamingClientServiceTest { + private class MockHandler : HttpMessageHandler { + public Func> Handler { get; set; } = req => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Handler(request); + } + + [Fact] + public async Task SearchSongsAsync_ReturnsSongs() { + var response = new SoundCloudSearchResponse( + [ + new SoundCloudTrack(1, "Track 1", 1000, null, new SoundCloudUser(10, "Artist 1", "http://avatar"), new SoundCloudMedia([])) + ], + null + ); + var json = JsonSerializer.Serialize(response); + + var handler = new MockHandler { + Handler = req => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(json) + }) + }; + + var httpClient = new HttpClient(handler); + var client = SoundCloudStreamingClient.CreateForTesting(httpClient); + + var results = await client.SearchSongsAsync("query", TestContext.Current.CancellationToken); + + Assert.Single(results); + Assert.Equal("Track 1", results[0].Title); + Assert.Equal("1", results[0].Id); + } + + [Fact] + public async Task GetSongStreamAsync_ReturnsStream() { + var track = new SoundCloudTrack(1, "Track 1", 1000, null, new SoundCloudUser(10, "Artist 1", "http://avatar"), new SoundCloudMedia( + [ + new SoundCloudTranscoding("http://stream-meta/v1", "mp3", new SoundCloudFormat("progressive", "audio/mpeg")) + ] + )); + var streamInfo = new SoundCloudStreamResponse("http://actual-stream-url"); + + var handler = new MockHandler { + Handler = req => { + var url = req.RequestUri!.ToString(); + if (url.Contains("/tracks/1")) { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(JsonSerializer.Serialize(track)) + }); + } + if (url.Contains("stream-meta")) { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(JsonSerializer.Serialize(streamInfo)) + }); + } + if (url.Contains("actual-stream-url")) { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + Content = new ByteArrayContent([1, 2, 3, 4]) + }); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + }; + + var httpClient = new HttpClient(handler); + var client = SoundCloudStreamingClient.CreateForTesting(httpClient); + + var songStream = await client.GetSongStreamAsync("1", TestContext.Current.CancellationToken); + + Assert.Equal("1", songStream.Id); + Assert.Equal("mp3", songStream.Codec); + + var buffer = new byte[4]; + var read = await songStream.Stream.ReadAsync(buffer, TestContext.Current.CancellationToken); + Assert.Equal(4, read); + Assert.Equal([1, 2, 3, 4], buffer); + } +} \ No newline at end of file diff --git a/smoc/Configuration/SoundCloudConfig.cs b/smoc/Configuration/SoundCloudConfig.cs new file mode 100644 index 0000000..cc12a31 --- /dev/null +++ b/smoc/Configuration/SoundCloudConfig.cs @@ -0,0 +1,20 @@ +using Terminal.Gui.Configuration; + +namespace Smoc.Configuration; + +/// +/// Configuration for SoundCloud. +/// +public static class SoundCloudConfig { + /// + /// Gets or sets the SoundCloud client ID. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string? ClientId { get; set; } = null; + + /// + /// Gets or sets the SoundCloud authentication token. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string? AuthToken { get; set; } = null; +} \ No newline at end of file diff --git a/smoc/Configuration/StreamingService.cs b/smoc/Configuration/StreamingService.cs index a427ce2..34b464a 100644 --- a/smoc/Configuration/StreamingService.cs +++ b/smoc/Configuration/StreamingService.cs @@ -12,5 +12,10 @@ public enum StreamingService { /// /// Subsonic compatible API. /// - Subsonic -} + Subsonic, + + /// + /// SoundCloud. + /// + SoundCloud +} \ No newline at end of file diff --git a/smoc/Program.cs b/smoc/Program.cs index 0f44ec3..a44a5cc 100644 --- a/smoc/Program.cs +++ b/smoc/Program.cs @@ -7,6 +7,7 @@ using Smoc.Streaming; using Smoc.Streaming.Subsonic; using Smoc.Streaming.YouTubeMusic; +using Smoc.Streaming.SoundCloud; using Smoc.Ui; using Terminal.Gui; using Terminal.Gui.App; @@ -104,6 +105,10 @@ private static IStreamingClient CreateStreamingClient() { Logging.Information("Creating Subsonic streaming client..."); return SubsonicStreamingClient.Create(songCache, artCache); + case StreamingService.SoundCloud: + Logging.Information("Creating SoundCloud streaming client..."); + return SoundCloudStreamingClient.Create(songCache, artCache); + case StreamingService.YouTubeMusic: Logging.Information("Creating YouTube Music streaming client..."); if (!File.Exists(_cookiesPath) || !File.Exists(_tokensPath)) { diff --git a/smoc/Streaming/SoundCloud/Models/SoundCloudPlaylist.cs b/smoc/Streaming/SoundCloud/Models/SoundCloudPlaylist.cs new file mode 100644 index 0000000..4d273e8 --- /dev/null +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudPlaylist.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.SoundCloud.Models; + +public record SoundCloudPlaylist( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("tracks")] List Tracks +); \ No newline at end of file diff --git a/smoc/Streaming/SoundCloud/Models/SoundCloudSearchResponse.cs b/smoc/Streaming/SoundCloud/Models/SoundCloudSearchResponse.cs new file mode 100644 index 0000000..4ea7f9d --- /dev/null +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudSearchResponse.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.SoundCloud.Models; + +public record SoundCloudSearchResponse( + [property: JsonPropertyName("collection")] List Collection, + [property: JsonPropertyName("next_href")] string? NextHref +); \ No newline at end of file diff --git a/smoc/Streaming/SoundCloud/Models/SoundCloudStreamResponse.cs b/smoc/Streaming/SoundCloud/Models/SoundCloudStreamResponse.cs new file mode 100644 index 0000000..c0ac79b --- /dev/null +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudStreamResponse.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.SoundCloud.Models; + +public record SoundCloudStreamResponse( + [property: JsonPropertyName("url")] string Url +); \ No newline at end of file diff --git a/smoc/Streaming/SoundCloud/Models/SoundCloudTrack.cs b/smoc/Streaming/SoundCloud/Models/SoundCloudTrack.cs new file mode 100644 index 0000000..67c2067 --- /dev/null +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudTrack.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.SoundCloud.Models; + +public record SoundCloudTrack( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("duration")] long Duration, + [property: JsonPropertyName("artwork_url")] string? ArtworkUrl, + [property: JsonPropertyName("user")] SoundCloudUser User, + [property: JsonPropertyName("media")] SoundCloudMedia Media +); + +public record SoundCloudMedia( + [property: JsonPropertyName("transcodings")] List Transcodings +); + +public record SoundCloudTranscoding( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("preset")] string Preset, + [property: JsonPropertyName("format")] SoundCloudFormat Format +); + +public record SoundCloudFormat( + [property: JsonPropertyName("protocol")] string Protocol, + [property: JsonPropertyName("mime_type")] string MimeType +); \ No newline at end of file diff --git a/smoc/Streaming/SoundCloud/Models/SoundCloudUser.cs b/smoc/Streaming/SoundCloud/Models/SoundCloudUser.cs new file mode 100644 index 0000000..bedbb2e --- /dev/null +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudUser.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.SoundCloud.Models; + +public record SoundCloudUser( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("username")] string Username, + [property: JsonPropertyName("avatar_url")] string AvatarUrl +); \ No newline at end of file diff --git a/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs b/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs new file mode 100644 index 0000000..ac4d089 --- /dev/null +++ b/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs @@ -0,0 +1,192 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Terminal.Gui.App; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Smoc.Configuration; +using Smoc.Services; +using Smoc.Services.Caching; +using Smoc.Streaming.SoundCloud.Models; +using Smoc.Streaming.SoundCloud.Util; +using Smoc.Ui.Drawing; + +namespace Smoc.Streaming.SoundCloud; + +public sealed class SoundCloudStreamingClient : IStreamingClient { + private static readonly string SoundCloudUrl = "https://soundcloud.com"; + private static readonly string ApiUrl = "https://api-v2.soundcloud.com"; + private readonly HttpClient _httpClient; + private readonly ICacheService _songCacheService; + private readonly ICacheService _albumArtCacheService; + private string? _clientId; + + private SoundCloudStreamingClient(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + _songCacheService = songCacheService ?? new NoCachingCacheService(); + _albumArtCacheService = albumArtCacheService ?? new NoCachingCacheService(); + _clientId = SoundCloudConfig.ClientId; + } + + public static SoundCloudStreamingClient CreateForTesting(HttpClient httpClient, ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null, string? clientId = "test-client-id") { + var client = new SoundCloudStreamingClient(songCacheService, albumArtCacheService); + var httpClientField = typeof(SoundCloudStreamingClient).GetField("_httpClient", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + httpClientField?.SetValue(client, httpClient); + client._clientId = clientId; + return client; + } + + public static SoundCloudStreamingClient Create(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { + return new SoundCloudStreamingClient(songCacheService, albumArtCacheService); + } + + private async Task GetClientIdAsync(CancellationToken cancellationToken = default) { + if (!string.IsNullOrEmpty(_clientId)) { + return _clientId; + } + + Logging.Information("Discovering SoundCloud Client ID..."); + var response = await _httpClient.GetStringAsync(SoundCloudUrl, cancellationToken); + + foreach (var scriptUrl in SoundCloudDiscovery.ExtractScriptUrls(response)) { + if (!scriptUrl.StartsWith("http")) { + continue; + } + + var scriptContent = await _httpClient.GetStringAsync(scriptUrl, cancellationToken); + var clientId = SoundCloudDiscovery.ExtractClientId(scriptContent); + if (clientId != null) { + _clientId = clientId; + Logging.Information($"Discovered SoundCloud Client ID: {_clientId}"); + return _clientId; + } + } + + throw new InvalidOperationException("Could not discover SoundCloud Client ID."); + } + + private async Task GetAsync(string endpoint, Dictionary? parameters = null, CancellationToken cancellationToken = default) { + var clientId = await GetClientIdAsync(cancellationToken); + var url = endpoint.StartsWith("http") ? endpoint : $"{ApiUrl}/{endpoint.TrimStart('/')}"; + var queryParams = parameters ?? new Dictionary(); + queryParams["client_id"] = clientId; + + var queryString = string.Join("&", queryParams.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); + var fullUrl = url.Contains('?') ? $"{url}&{queryString}" : $"{url}?{queryString}"; + + return await _httpClient.GetFromJsonAsync(fullUrl, cancellationToken) ?? throw new InvalidOperationException("API returned null."); + } + + public async Task> SearchArtistsAsync(string query, CancellationToken cancellationToken = default) { + var response = await GetAsync>("search/users", new Dictionary { { "q", query }, { "limit", "20" } }, cancellationToken); + return response.Collection.Select(u => new Artist(u.Id.ToString(), u.Username)).ToList(); + } + + public async Task> SearchSongsAsync(string query, CancellationToken cancellationToken = default) { + var response = await GetAsync>("search/tracks", new Dictionary { { "q", query }, { "limit", "20" } }, cancellationToken); + return response.Collection.Select(SoundCloudMapper.MapTrackToSong).ToList(); + } + + public async Task GetSongAsync(string songId, CancellationToken cancellationToken = default) { + var track = await GetAsync($"/tracks/{songId}", null, cancellationToken); + return SoundCloudMapper.MapTrackToSong(track); + } + + public async Task GetArtistAsync(string artistId, CancellationToken cancellationToken = default) { + var user = await GetAsync($"/users/{artistId}", null, cancellationToken); + return new Artist(user.Id.ToString(), user.Username); + } + + public async Task> GetAlbumsByArtistAsync(Artist artist, CancellationToken cancellationToken = default) { + // SoundCloud doesn't have a direct "albums" for all artists that map 1:1. + // We'll treat "SoundCloud Uploads" as a default album. + return [new Album($"sc-uploads-{artist.Id}", artist, "SoundCloud Uploads", [])]; + } + + public async Task> GetSongsByAlbumAsync(Album album, CancellationToken cancellationToken = default) { + if (album.Id.StartsWith("sc-uploads-")) { + var userId = album.Id.Replace("sc-uploads-", ""); + var response = await GetAsync>($"/users/{userId}/tracks", new Dictionary { { "limit", "50" } }, cancellationToken); + return response.Collection.Select(SoundCloudMapper.MapTrackToSong).ToList(); + } + return []; + } + + public async Task GetSongStreamAsync(string songId, CancellationToken cancellationToken = default) { + var track = await GetAsync($"/tracks/{songId}", null, cancellationToken); + + var transcoding = track.Media.Transcodings.FirstOrDefault(t => t.Format.Protocol == "progressive" && t.Format.MimeType == "audio/mpeg") + ?? track.Media.Transcodings.FirstOrDefault(t => t.Format.Protocol == "hls" && t.Format.MimeType == "audio/mpeg") + ?? track.Media.Transcodings.FirstOrDefault(); + + if (transcoding == null) { + throw new InvalidOperationException("No playable transcoding found."); + } + + var streamResponse = await GetAsync(transcoding.Url, null, cancellationToken); + + var stream = await _songCacheService.GetOrAddAsync( + $"{songId}-{transcoding.Preset}", + async ct => await _httpClient.GetStreamAsync(streamResponse.Url, ct), + cancellationToken); + + var codec = transcoding.Format.Protocol == "hls" ? "hls" : "mp3"; + return new SongStream(songId, codec, stream); + } + + public async Task> GetLikedSongsAsync(CancellationToken cancellationToken = default) { + // Guest access doesn't support likes. Phase 2 would require AuthToken. + return []; + } + + public async Task> SearchPlaylistsAsync(string query, CancellationToken cancellationToken = default) { + var response = await GetAsync>("search/playlists", new Dictionary { { "q", query }, { "limit", "20" } }, cancellationToken); + return response.Collection.Select(p => new Playlist(p.Id.ToString(), p.Title)).ToList(); + } + + public async Task> GetPlaylistSongsAsync(Playlist playlist, CancellationToken cancellationToken = default) { + var scPlaylist = await GetAsync($"/playlists/{playlist.Id}", null, cancellationToken); + return scPlaylist.Tracks.Select(SoundCloudMapper.MapTrackToSong).ToList(); + } + + public async Task> GetPlaylistSongsFromUrlAsync(string url, CancellationToken cancellationToken = default) { + var result = await GetAsync("resolve", new Dictionary { { "url", url } }, cancellationToken); + + if (result.TryGetProperty("kind", out var kind)) { + var kindStr = kind.GetString(); + var json = result.GetRawText(); + if (kindStr == "playlist") { + var playlist = System.Text.Json.JsonSerializer.Deserialize(json); + return playlist?.Tracks.Select(SoundCloudMapper.MapTrackToSong).ToList() ?? []; + } else if (kindStr == "track") { + var track = System.Text.Json.JsonSerializer.Deserialize(json); + return track != null ? [SoundCloudMapper.MapTrackToSong(track)] : []; + } + } + + return []; + } + + public async Task AddToListenHistory(Song song, CancellationToken cancellationToken = default) { + await Task.CompletedTask; + } + + public async Task> GetAlbumArtAsync(Album album, Func, AlbumCover>? coverSelector = null, CancellationToken cancellationToken = default) { + if (!album.Covers.Any()) + throw new ArgumentException("Album has no covers.", nameof(album)); + + var cover = coverSelector?.Invoke(album.Covers) ?? album.Covers.First(); + + using var albumArt = await _albumArtCacheService.GetOrAddAsync( + string.Concat(album.Id, "-", cover.Width, "x", cover.Height), + async ct => { + var albumResponse = await _httpClient.GetAsync(cover.Url, cancellationToken); + return await albumResponse.Content.ReadAsStreamAsync(cancellationToken); + }, + cancellationToken); + return await Image.LoadAsync(albumArt, cancellationToken); + } + + } \ No newline at end of file diff --git a/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs b/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs new file mode 100644 index 0000000..f4c1a6e --- /dev/null +++ b/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs @@ -0,0 +1,17 @@ +using System.Text.RegularExpressions; + +namespace Smoc.Streaming.SoundCloud.Util; + +public static class SoundCloudDiscovery { + private static readonly Regex ScriptRegex = new Regex("]+src=\"([^\"]+)\"", RegexOptions.IgnoreCase); + private static readonly Regex ClientIdRegex = new Regex("client_id:\"([a-zA-Z0-9]{32})\""); + + public static IEnumerable ExtractScriptUrls(string html) { + return ScriptRegex.Matches(html).Select(m => m.Groups[1].Value); + } + + public static string? ExtractClientId(string scriptContent) { + var match = ClientIdRegex.Match(scriptContent); + return match.Success ? match.Groups[1].Value : null; + } +} \ No newline at end of file diff --git a/smoc/Streaming/SoundCloud/Util/SoundCloudMapper.cs b/smoc/Streaming/SoundCloud/Util/SoundCloudMapper.cs new file mode 100644 index 0000000..9e15573 --- /dev/null +++ b/smoc/Streaming/SoundCloud/Util/SoundCloudMapper.cs @@ -0,0 +1,13 @@ +using Smoc.Streaming.SoundCloud.Models; + +namespace Smoc.Streaming.SoundCloud.Util; + +public static class SoundCloudMapper { + public static Smoc.Streaming.Song MapTrackToSong(SoundCloudTrack track) { + var artist = new Smoc.Streaming.Artist(track.User.Id.ToString(), track.User.Username); + var album = new Smoc.Streaming.Album($"sc-uploads-{track.User.Id}", artist, "SoundCloud Uploads", + string.IsNullOrEmpty(track.ArtworkUrl) ? [] : [new Smoc.Streaming.AlbumCover(track.ArtworkUrl.Replace("-large", "-t500x500"), 500, 500)]); + + return new Smoc.Streaming.Song(track.Id.ToString(), album, track.Title, TimeSpan.FromMilliseconds(track.Duration)); + } +} \ No newline at end of file From 1b5f815b4cdd850d03c1a4bd8ac5a5770297987d Mon Sep 17 00:00:00 2001 From: oca-agent <277152249+oca-agent@users.noreply.github.com> Date: Fri, 15 May 2026 22:09:15 -0400 Subject: [PATCH 2/3] docs: add documentation comments to SoundCloud integration classes and methods --- .../SoundCloud/SoundCloudDiscoveryTest.cs | 12 ++++++++ .../SoundCloud/SoundCloudMappingTest.cs | 9 ++++++ .../SoundCloudStreamingClientServiceTest.cs | 9 ++++++ .../SoundCloud/Models/SoundCloudPlaylist.cs | 6 ++++ .../Models/SoundCloudSearchResponse.cs | 6 ++++ .../Models/SoundCloudStreamResponse.cs | 4 +++ .../SoundCloud/Models/SoundCloudTrack.cs | 24 +++++++++++++++ .../SoundCloud/Models/SoundCloudUser.cs | 6 ++++ .../SoundCloud/SoundCloudStreamingClient.cs | 30 +++++++++++++++++++ .../SoundCloud/Util/SoundCloudDiscovery.cs | 13 ++++++++ .../SoundCloud/Util/SoundCloudMapper.cs | 8 +++++ 11 files changed, 127 insertions(+) diff --git a/smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs b/smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs index d3822a6..849108d 100644 --- a/smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs +++ b/smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs @@ -2,7 +2,13 @@ namespace smoc.Tests.Streaming.SoundCloud; +/// +/// Tests for the class. +/// public class SoundCloudDiscoveryTest { + /// + /// Verifies that script URLs can be extracted from HTML. + /// [Fact] public void ExtractScriptUrls_FindsScripts() { var html = ""; @@ -12,6 +18,9 @@ public void ExtractScriptUrls_FindsScripts() { Assert.Equal("https://a-v2.sndcdn.com/assets/2.js", urls[1]); } + /// + /// Verifies that the client ID can be extracted from script content. + /// [Fact] public void ExtractClientId_FindsId() { var content = "window.Snd={}; Snd.config={client_id:\"0123456789abcdef0123456789abcdef\", ...}"; @@ -19,6 +28,9 @@ public void ExtractClientId_FindsId() { Assert.Equal("0123456789abcdef0123456789abcdef", id); } + /// + /// Verifies that null is returned if the client ID is not found. + /// [Fact] public void ExtractClientId_NotFound_ReturnsNull() { var content = "console.log('hello');"; diff --git a/smoc.Tests/Streaming/SoundCloud/SoundCloudMappingTest.cs b/smoc.Tests/Streaming/SoundCloud/SoundCloudMappingTest.cs index d733767..385ca2e 100644 --- a/smoc.Tests/Streaming/SoundCloud/SoundCloudMappingTest.cs +++ b/smoc.Tests/Streaming/SoundCloud/SoundCloudMappingTest.cs @@ -3,7 +3,13 @@ namespace smoc.Tests.Streaming.SoundCloud; +/// +/// Tests for the class. +/// public class SoundCloudMappingTest { + /// + /// Verifies that a SoundCloud track is correctly mapped to a SMoC song. + /// [Fact] public void MapTrackToSong_ReturnsCorrectSong() { var user = new SoundCloudUser(123, "Test Artist", "http://avatar"); @@ -22,6 +28,9 @@ public void MapTrackToSong_ReturnsCorrectSong() { Assert.Equal("http://artwork-t500x500.jpg", song.Album.Covers.First().Url); } + /// + /// Verifies that a SoundCloud track with no artwork is correctly mapped. + /// [Fact] public void MapTrackToSong_NoArtwork_ReturnsEmptyCovers() { var user = new SoundCloudUser(123, "Test Artist", "http://avatar"); diff --git a/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs b/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs index a0fef79..b3a06c0 100644 --- a/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs +++ b/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs @@ -7,12 +7,18 @@ namespace smoc.Tests.Streaming.SoundCloud; +/// +/// Tests for the class. +/// public class SoundCloudStreamingClientServiceTest { private class MockHandler : HttpMessageHandler { public Func> Handler { get; set; } = req => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Handler(request); } + /// + /// Verifies that songs can be searched. + /// [Fact] public async Task SearchSongsAsync_ReturnsSongs() { var response = new SoundCloudSearchResponse( @@ -39,6 +45,9 @@ public async Task SearchSongsAsync_ReturnsSongs() { Assert.Equal("1", results[0].Id); } + /// + /// Verifies that a song stream can be retrieved. + /// [Fact] public async Task GetSongStreamAsync_ReturnsStream() { var track = new SoundCloudTrack(1, "Track 1", 1000, null, new SoundCloudUser(10, "Artist 1", "http://avatar"), new SoundCloudMedia( diff --git a/smoc/Streaming/SoundCloud/Models/SoundCloudPlaylist.cs b/smoc/Streaming/SoundCloud/Models/SoundCloudPlaylist.cs index 4d273e8..cc0abd3 100644 --- a/smoc/Streaming/SoundCloud/Models/SoundCloudPlaylist.cs +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudPlaylist.cs @@ -2,6 +2,12 @@ namespace Smoc.Streaming.SoundCloud.Models; +/// +/// Represents a SoundCloud playlist. +/// +/// The playlist ID. +/// The playlist title. +/// The tracks in the playlist. public record SoundCloudPlaylist( [property: JsonPropertyName("id")] long Id, [property: JsonPropertyName("title")] string Title, diff --git a/smoc/Streaming/SoundCloud/Models/SoundCloudSearchResponse.cs b/smoc/Streaming/SoundCloud/Models/SoundCloudSearchResponse.cs index 4ea7f9d..b6ada2f 100644 --- a/smoc/Streaming/SoundCloud/Models/SoundCloudSearchResponse.cs +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudSearchResponse.cs @@ -2,6 +2,12 @@ namespace Smoc.Streaming.SoundCloud.Models; +/// +/// Represents a SoundCloud search response. +/// +/// The type of the collection items. +/// The collection of items. +/// The URL for the next page of results. public record SoundCloudSearchResponse( [property: JsonPropertyName("collection")] List Collection, [property: JsonPropertyName("next_href")] string? NextHref diff --git a/smoc/Streaming/SoundCloud/Models/SoundCloudStreamResponse.cs b/smoc/Streaming/SoundCloud/Models/SoundCloudStreamResponse.cs index c0ac79b..5b9b01f 100644 --- a/smoc/Streaming/SoundCloud/Models/SoundCloudStreamResponse.cs +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudStreamResponse.cs @@ -2,6 +2,10 @@ namespace Smoc.Streaming.SoundCloud.Models; +/// +/// Represents a SoundCloud stream response. +/// +/// The stream URL. public record SoundCloudStreamResponse( [property: JsonPropertyName("url")] string Url ); \ No newline at end of file diff --git a/smoc/Streaming/SoundCloud/Models/SoundCloudTrack.cs b/smoc/Streaming/SoundCloud/Models/SoundCloudTrack.cs index 67c2067..d7482c0 100644 --- a/smoc/Streaming/SoundCloud/Models/SoundCloudTrack.cs +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudTrack.cs @@ -2,6 +2,15 @@ namespace Smoc.Streaming.SoundCloud.Models; +/// +/// Represents a SoundCloud track. +/// +/// The track ID. +/// The track title. +/// The track duration in milliseconds. +/// The track artwork URL. +/// The user who uploaded the track. +/// The track media info. public record SoundCloudTrack( [property: JsonPropertyName("id")] long Id, [property: JsonPropertyName("title")] string Title, @@ -11,16 +20,31 @@ public record SoundCloudTrack( [property: JsonPropertyName("media")] SoundCloudMedia Media ); +/// +/// Represents SoundCloud media info. +/// +/// The list of transcodings. public record SoundCloudMedia( [property: JsonPropertyName("transcodings")] List Transcodings ); +/// +/// Represents a SoundCloud transcoding. +/// +/// The transcoding URL. +/// The transcoding preset. +/// The transcoding format. public record SoundCloudTranscoding( [property: JsonPropertyName("url")] string Url, [property: JsonPropertyName("preset")] string Preset, [property: JsonPropertyName("format")] SoundCloudFormat Format ); +/// +/// Represents a SoundCloud format. +/// +/// The format protocol. +/// The format MIME type. public record SoundCloudFormat( [property: JsonPropertyName("protocol")] string Protocol, [property: JsonPropertyName("mime_type")] string MimeType diff --git a/smoc/Streaming/SoundCloud/Models/SoundCloudUser.cs b/smoc/Streaming/SoundCloud/Models/SoundCloudUser.cs index bedbb2e..23b51d2 100644 --- a/smoc/Streaming/SoundCloud/Models/SoundCloudUser.cs +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudUser.cs @@ -2,6 +2,12 @@ namespace Smoc.Streaming.SoundCloud.Models; +/// +/// Represents a SoundCloud user. +/// +/// The user ID. +/// The username. +/// The user avatar URL. public record SoundCloudUser( [property: JsonPropertyName("id")] long Id, [property: JsonPropertyName("username")] string Username, diff --git a/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs b/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs index ac4d089..4b63d86 100644 --- a/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs +++ b/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs @@ -14,6 +14,9 @@ namespace Smoc.Streaming.SoundCloud; +/// +/// A streaming client for SoundCloud. +/// public sealed class SoundCloudStreamingClient : IStreamingClient { private static readonly string SoundCloudUrl = "https://soundcloud.com"; private static readonly string ApiUrl = "https://api-v2.soundcloud.com"; @@ -30,6 +33,14 @@ private SoundCloudStreamingClient(ICacheService? songCacheService = null, ICache _clientId = SoundCloudConfig.ClientId; } + /// + /// Creates a new instance of for testing. + /// + /// The HTTP client to use. + /// The song cache service. + /// The album art cache service. + /// The SoundCloud client ID. + /// A new instance of . public static SoundCloudStreamingClient CreateForTesting(HttpClient httpClient, ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null, string? clientId = "test-client-id") { var client = new SoundCloudStreamingClient(songCacheService, albumArtCacheService); var httpClientField = typeof(SoundCloudStreamingClient).GetField("_httpClient", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); @@ -38,6 +49,12 @@ public static SoundCloudStreamingClient CreateForTesting(HttpClient httpClient, return client; } + /// + /// Creates a new instance of . + /// + /// The song cache service. + /// The album art cache service. + /// A new instance of . public static SoundCloudStreamingClient Create(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { return new SoundCloudStreamingClient(songCacheService, albumArtCacheService); } @@ -79,32 +96,38 @@ private async Task GetAsync(string endpoint, Dictionary? p return await _httpClient.GetFromJsonAsync(fullUrl, cancellationToken) ?? throw new InvalidOperationException("API returned null."); } + /// public async Task> SearchArtistsAsync(string query, CancellationToken cancellationToken = default) { var response = await GetAsync>("search/users", new Dictionary { { "q", query }, { "limit", "20" } }, cancellationToken); return response.Collection.Select(u => new Artist(u.Id.ToString(), u.Username)).ToList(); } + /// public async Task> SearchSongsAsync(string query, CancellationToken cancellationToken = default) { var response = await GetAsync>("search/tracks", new Dictionary { { "q", query }, { "limit", "20" } }, cancellationToken); return response.Collection.Select(SoundCloudMapper.MapTrackToSong).ToList(); } + /// public async Task GetSongAsync(string songId, CancellationToken cancellationToken = default) { var track = await GetAsync($"/tracks/{songId}", null, cancellationToken); return SoundCloudMapper.MapTrackToSong(track); } + /// public async Task GetArtistAsync(string artistId, CancellationToken cancellationToken = default) { var user = await GetAsync($"/users/{artistId}", null, cancellationToken); return new Artist(user.Id.ToString(), user.Username); } + /// public async Task> GetAlbumsByArtistAsync(Artist artist, CancellationToken cancellationToken = default) { // SoundCloud doesn't have a direct "albums" for all artists that map 1:1. // We'll treat "SoundCloud Uploads" as a default album. return [new Album($"sc-uploads-{artist.Id}", artist, "SoundCloud Uploads", [])]; } + /// public async Task> GetSongsByAlbumAsync(Album album, CancellationToken cancellationToken = default) { if (album.Id.StartsWith("sc-uploads-")) { var userId = album.Id.Replace("sc-uploads-", ""); @@ -114,6 +137,7 @@ public async Task> GetSongsByAlbumAsync(Album album, CancellationToke return []; } + /// public async Task GetSongStreamAsync(string songId, CancellationToken cancellationToken = default) { var track = await GetAsync($"/tracks/{songId}", null, cancellationToken); @@ -136,21 +160,25 @@ public async Task GetSongStreamAsync(string songId, CancellationToke return new SongStream(songId, codec, stream); } + /// public async Task> GetLikedSongsAsync(CancellationToken cancellationToken = default) { // Guest access doesn't support likes. Phase 2 would require AuthToken. return []; } + /// public async Task> SearchPlaylistsAsync(string query, CancellationToken cancellationToken = default) { var response = await GetAsync>("search/playlists", new Dictionary { { "q", query }, { "limit", "20" } }, cancellationToken); return response.Collection.Select(p => new Playlist(p.Id.ToString(), p.Title)).ToList(); } + /// public async Task> GetPlaylistSongsAsync(Playlist playlist, CancellationToken cancellationToken = default) { var scPlaylist = await GetAsync($"/playlists/{playlist.Id}", null, cancellationToken); return scPlaylist.Tracks.Select(SoundCloudMapper.MapTrackToSong).ToList(); } + /// public async Task> GetPlaylistSongsFromUrlAsync(string url, CancellationToken cancellationToken = default) { var result = await GetAsync("resolve", new Dictionary { { "url", url } }, cancellationToken); @@ -169,10 +197,12 @@ public async Task> GetPlaylistSongsFromUrlAsync(string url, Cancellat return []; } + /// public async Task AddToListenHistory(Song song, CancellationToken cancellationToken = default) { await Task.CompletedTask; } + /// public async Task> GetAlbumArtAsync(Album album, Func, AlbumCover>? coverSelector = null, CancellationToken cancellationToken = default) { if (!album.Covers.Any()) throw new ArgumentException("Album has no covers.", nameof(album)); diff --git a/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs b/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs index f4c1a6e..5056e32 100644 --- a/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs +++ b/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs @@ -2,14 +2,27 @@ namespace Smoc.Streaming.SoundCloud.Util; +/// +/// Provides methods for discovering SoundCloud client configuration from the web page. +/// public static class SoundCloudDiscovery { private static readonly Regex ScriptRegex = new Regex("]+src=\"([^\"]+)\"", RegexOptions.IgnoreCase); private static readonly Regex ClientIdRegex = new Regex("client_id:\"([a-zA-Z0-9]{32})\""); + /// + /// Extracts script URLs from the provided HTML. + /// + /// The HTML to extract URLs from. + /// A collection of script URLs. public static IEnumerable ExtractScriptUrls(string html) { return ScriptRegex.Matches(html).Select(m => m.Groups[1].Value); } + /// + /// Extracts the SoundCloud client ID from the provided script content. + /// + /// The script content to search. + /// The client ID if found; otherwise, null. public static string? ExtractClientId(string scriptContent) { var match = ClientIdRegex.Match(scriptContent); return match.Success ? match.Groups[1].Value : null; diff --git a/smoc/Streaming/SoundCloud/Util/SoundCloudMapper.cs b/smoc/Streaming/SoundCloud/Util/SoundCloudMapper.cs index 9e15573..64a0d41 100644 --- a/smoc/Streaming/SoundCloud/Util/SoundCloudMapper.cs +++ b/smoc/Streaming/SoundCloud/Util/SoundCloudMapper.cs @@ -2,7 +2,15 @@ namespace Smoc.Streaming.SoundCloud.Util; +/// +/// Provides methods for mapping SoundCloud models to SMoC streaming models. +/// public static class SoundCloudMapper { + /// + /// Maps a to a . + /// + /// The SoundCloud track to map. + /// The mapped SMoC song. public static Smoc.Streaming.Song MapTrackToSong(SoundCloudTrack track) { var artist = new Smoc.Streaming.Artist(track.User.Id.ToString(), track.User.Username); var album = new Smoc.Streaming.Album($"sc-uploads-{track.User.Id}", artist, "SoundCloud Uploads", From 1529be7f0e6afb31b0d696b194a72d2a144099e3 Mon Sep 17 00:00:00 2001 From: Matt Razza <504088+mrazza@users.noreply.github.com> Date: Fri, 15 May 2026 22:54:03 -0400 Subject: [PATCH 3/3] refactor: remove SoundCloud client ID discovery logic and clean up unused dependencies --- .../SoundCloudStreamingClientServiceTest.cs | 148 +++++++++--------- .../Audio/SoundFlow/SoundFlowAudioService.cs | 4 +- .../SoundCloud/SoundCloudStreamingClient.cs | 142 ++++++++--------- .../SoundCloud/Util/SoundCloudDiscovery.cs | 8 +- smoc/Ui/Components/SearchResultsList.cs | 4 +- 5 files changed, 147 insertions(+), 159 deletions(-) diff --git a/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs b/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs index b3a06c0..c9a7a3e 100644 --- a/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs +++ b/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs @@ -1,9 +1,7 @@ using System.Net; using System.Text.Json; -using Smoc.Streaming; using Smoc.Streaming.SoundCloud; using Smoc.Streaming.SoundCloud.Models; -using Smoc.Services.Caching; namespace smoc.Tests.Streaming.SoundCloud; @@ -11,85 +9,85 @@ namespace smoc.Tests.Streaming.SoundCloud; /// Tests for the class. /// public class SoundCloudStreamingClientServiceTest { - private class MockHandler : HttpMessageHandler { - public Func> Handler { get; set; } = req => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Handler(request); - } + private class MockHandler : HttpMessageHandler { + public Func> Handler { get; set; } = req => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Handler(request); + } - /// - /// Verifies that songs can be searched. - /// - [Fact] - public async Task SearchSongsAsync_ReturnsSongs() { - var response = new SoundCloudSearchResponse( - [ - new SoundCloudTrack(1, "Track 1", 1000, null, new SoundCloudUser(10, "Artist 1", "http://avatar"), new SoundCloudMedia([])) - ], - null - ); - var json = JsonSerializer.Serialize(response); - - var handler = new MockHandler { - Handler = req => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(json) - }) - }; + /// + /// Verifies that songs can be searched. + /// + [Fact] + public async Task SearchSongsAsync_ReturnsSongs() { + var response = new SoundCloudSearchResponse( + [ + new SoundCloudTrack(1, "Track 1", 1000, null, new SoundCloudUser(10, "Artist 1", "http://avatar"), new SoundCloudMedia([])) + ], + null + ); + var json = JsonSerializer.Serialize(response); - var httpClient = new HttpClient(handler); - var client = SoundCloudStreamingClient.CreateForTesting(httpClient); + var handler = new MockHandler { + Handler = req => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(json) + }) + }; - var results = await client.SearchSongsAsync("query", TestContext.Current.CancellationToken); + var httpClient = new HttpClient(handler); + var client = SoundCloudStreamingClient.CreateForTesting(httpClient); - Assert.Single(results); - Assert.Equal("Track 1", results[0].Title); - Assert.Equal("1", results[0].Id); - } + var results = await client.SearchSongsAsync("query", TestContext.Current.CancellationToken); - /// - /// Verifies that a song stream can be retrieved. - /// - [Fact] - public async Task GetSongStreamAsync_ReturnsStream() { - var track = new SoundCloudTrack(1, "Track 1", 1000, null, new SoundCloudUser(10, "Artist 1", "http://avatar"), new SoundCloudMedia( - [ - new SoundCloudTranscoding("http://stream-meta/v1", "mp3", new SoundCloudFormat("progressive", "audio/mpeg")) - ] - )); - var streamInfo = new SoundCloudStreamResponse("http://actual-stream-url"); + Assert.Single(results); + Assert.Equal("Track 1", results[0].Title); + Assert.Equal("1", results[0].Id); + } - var handler = new MockHandler { - Handler = req => { - var url = req.RequestUri!.ToString(); - if (url.Contains("/tracks/1")) { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(JsonSerializer.Serialize(track)) - }); - } - if (url.Contains("stream-meta")) { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(JsonSerializer.Serialize(streamInfo)) - }); - } - if (url.Contains("actual-stream-url")) { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { - Content = new ByteArrayContent([1, 2, 3, 4]) - }); - } - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - }; + /// + /// Verifies that a song stream can be retrieved. + /// + [Fact] + public async Task GetSongStreamAsync_ReturnsStream() { + var track = new SoundCloudTrack(1, "Track 1", 1000, null, new SoundCloudUser(10, "Artist 1", "http://avatar"), new SoundCloudMedia( + [ + new SoundCloudTranscoding("http://stream-meta/v1", "mp3", new SoundCloudFormat("progressive", "audio/mpeg")) + ] + )); + var streamInfo = new SoundCloudStreamResponse("http://actual-stream-url"); - var httpClient = new HttpClient(handler); - var client = SoundCloudStreamingClient.CreateForTesting(httpClient); - - var songStream = await client.GetSongStreamAsync("1", TestContext.Current.CancellationToken); + var handler = new MockHandler { + Handler = req => { + var url = req.RequestUri!.ToString(); + if (url.Contains("/tracks/1")) { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(JsonSerializer.Serialize(track)) + }); + } + if (url.Contains("stream-meta")) { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(JsonSerializer.Serialize(streamInfo)) + }); + } + if (url.Contains("actual-stream-url")) { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + Content = new ByteArrayContent([1, 2, 3, 4]) + }); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + }; - Assert.Equal("1", songStream.Id); - Assert.Equal("mp3", songStream.Codec); - - var buffer = new byte[4]; - var read = await songStream.Stream.ReadAsync(buffer, TestContext.Current.CancellationToken); - Assert.Equal(4, read); - Assert.Equal([1, 2, 3, 4], buffer); - } + var httpClient = new HttpClient(handler); + var client = SoundCloudStreamingClient.CreateForTesting(httpClient); + + var songStream = await client.GetSongStreamAsync("1", TestContext.Current.CancellationToken); + + Assert.Equal("1", songStream.Id); + Assert.Equal("mp3", songStream.Codec); + + var buffer = new byte[4]; + var read = await songStream.Stream.ReadAsync(buffer, TestContext.Current.CancellationToken); + Assert.Equal(4, read); + Assert.Equal([1, 2, 3, 4], buffer); + } } \ No newline at end of file diff --git a/smoc/Services/Audio/SoundFlow/SoundFlowAudioService.cs b/smoc/Services/Audio/SoundFlow/SoundFlowAudioService.cs index f04c220..5f367f1 100644 --- a/smoc/Services/Audio/SoundFlow/SoundFlowAudioService.cs +++ b/smoc/Services/Audio/SoundFlow/SoundFlowAudioService.cs @@ -16,8 +16,8 @@ public sealed class SoundFlowAudioService : IAudioService { /// public float Volume { - get => this._playbackDevice.MasterMixer.Volume / 2.0f; - set => this._playbackDevice.MasterMixer.Volume = value * 2.0f; + get => _playbackDevice.MasterMixer.Volume / 2.0f; + set => _playbackDevice.MasterMixer.Volume = value * 2.0f; } /// diff --git a/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs b/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs index 4b63d86..fc6a873 100644 --- a/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs +++ b/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs @@ -1,7 +1,4 @@ using System.Net.Http.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Microsoft.Extensions.Logging; using Terminal.Gui.App; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; @@ -10,7 +7,6 @@ using Smoc.Services.Caching; using Smoc.Streaming.SoundCloud.Models; using Smoc.Streaming.SoundCloud.Util; -using Smoc.Ui.Drawing; namespace Smoc.Streaming.SoundCloud; @@ -18,8 +14,8 @@ namespace Smoc.Streaming.SoundCloud; /// A streaming client for SoundCloud. /// public sealed class SoundCloudStreamingClient : IStreamingClient { - private static readonly string SoundCloudUrl = "https://soundcloud.com"; - private static readonly string ApiUrl = "https://api-v2.soundcloud.com"; + private static readonly string _soundCloudUrl = "https://soundcloud.com"; + private static readonly string _apiUrl = "https://api-v2.soundcloud.com"; private readonly HttpClient _httpClient; private readonly ICacheService _songCacheService; private readonly ICacheService _albumArtCacheService; @@ -59,53 +55,16 @@ public static SoundCloudStreamingClient Create(ICacheService? songCacheService = return new SoundCloudStreamingClient(songCacheService, albumArtCacheService); } - private async Task GetClientIdAsync(CancellationToken cancellationToken = default) { - if (!string.IsNullOrEmpty(_clientId)) { - return _clientId; - } - - Logging.Information("Discovering SoundCloud Client ID..."); - var response = await _httpClient.GetStringAsync(SoundCloudUrl, cancellationToken); - - foreach (var scriptUrl in SoundCloudDiscovery.ExtractScriptUrls(response)) { - if (!scriptUrl.StartsWith("http")) { - continue; - } - - var scriptContent = await _httpClient.GetStringAsync(scriptUrl, cancellationToken); - var clientId = SoundCloudDiscovery.ExtractClientId(scriptContent); - if (clientId != null) { - _clientId = clientId; - Logging.Information($"Discovered SoundCloud Client ID: {_clientId}"); - return _clientId; - } - } - - throw new InvalidOperationException("Could not discover SoundCloud Client ID."); - } - - private async Task GetAsync(string endpoint, Dictionary? parameters = null, CancellationToken cancellationToken = default) { - var clientId = await GetClientIdAsync(cancellationToken); - var url = endpoint.StartsWith("http") ? endpoint : $"{ApiUrl}/{endpoint.TrimStart('/')}"; - var queryParams = parameters ?? new Dictionary(); - queryParams["client_id"] = clientId; - - var queryString = string.Join("&", queryParams.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); - var fullUrl = url.Contains('?') ? $"{url}&{queryString}" : $"{url}?{queryString}"; - - return await _httpClient.GetFromJsonAsync(fullUrl, cancellationToken) ?? throw new InvalidOperationException("API returned null."); - } - /// public async Task> SearchArtistsAsync(string query, CancellationToken cancellationToken = default) { var response = await GetAsync>("search/users", new Dictionary { { "q", query }, { "limit", "20" } }, cancellationToken); - return response.Collection.Select(u => new Artist(u.Id.ToString(), u.Username)).ToList(); + return [.. response.Collection.Select(u => new Artist(u.Id.ToString(), u.Username))]; } /// public async Task> SearchSongsAsync(string query, CancellationToken cancellationToken = default) { var response = await GetAsync>("search/tracks", new Dictionary { { "q", query }, { "limit", "20" } }, cancellationToken); - return response.Collection.Select(SoundCloudMapper.MapTrackToSong).ToList(); + return [.. response.Collection.Select(SoundCloudMapper.MapTrackToSong)]; } /// @@ -121,18 +80,18 @@ public async Task GetArtistAsync(string artistId, CancellationToken canc } /// - public async Task> GetAlbumsByArtistAsync(Artist artist, CancellationToken cancellationToken = default) { + public Task> GetAlbumsByArtistAsync(Artist artist, CancellationToken cancellationToken = default) { // SoundCloud doesn't have a direct "albums" for all artists that map 1:1. // We'll treat "SoundCloud Uploads" as a default album. - return [new Album($"sc-uploads-{artist.Id}", artist, "SoundCloud Uploads", [])]; + return Task.FromResult>([new Album($"sc-uploads-{artist.Id}", artist, "SoundCloud Uploads", [])]); } /// public async Task> GetSongsByAlbumAsync(Album album, CancellationToken cancellationToken = default) { if (album.Id.StartsWith("sc-uploads-")) { var userId = album.Id.Replace("sc-uploads-", ""); - var response = await GetAsync>($"/users/{userId}/tracks", new Dictionary { { "limit", "50" } }, cancellationToken); - return response.Collection.Select(SoundCloudMapper.MapTrackToSong).ToList(); + var response = await GetAsync>($"/users/{userId}/tracks", new Dictionary { { "limit", "50" }, { "access", "[playable]" } }, cancellationToken); + return [.. response.Collection.Select(SoundCloudMapper.MapTrackToSong)]; } return []; } @@ -140,20 +99,15 @@ public async Task> GetSongsByAlbumAsync(Album album, CancellationToke /// public async Task GetSongStreamAsync(string songId, CancellationToken cancellationToken = default) { var track = await GetAsync($"/tracks/{songId}", null, cancellationToken); - - var transcoding = track.Media.Transcodings.FirstOrDefault(t => t.Format.Protocol == "progressive" && t.Format.MimeType == "audio/mpeg") + var transcoding = (track.Media.Transcodings.FirstOrDefault(t => t.Format.Protocol == "progressive" && t.Format.MimeType == "audio/mpeg") ?? track.Media.Transcodings.FirstOrDefault(t => t.Format.Protocol == "hls" && t.Format.MimeType == "audio/mpeg") - ?? track.Media.Transcodings.FirstOrDefault(); - - if (transcoding == null) { - throw new InvalidOperationException("No playable transcoding found."); - } - - var streamResponse = await GetAsync(transcoding.Url, null, cancellationToken); - + ?? track.Media.Transcodings.FirstOrDefault()) ?? throw new InvalidOperationException("No playable transcoding found."); var stream = await _songCacheService.GetOrAddAsync( - $"{songId}-{transcoding.Preset}", - async ct => await _httpClient.GetStreamAsync(streamResponse.Url, ct), + $"soundcloud-{songId}-{transcoding.Preset}", + async ct => { + var streamResponse = await GetAsync(transcoding.Url, null, cancellationToken); + return await _httpClient.GetStreamAsync(streamResponse.Url, ct); + }, cancellationToken); var codec = transcoding.Format.Protocol == "hls" ? "hls" : "mp3"; @@ -161,9 +115,9 @@ public async Task GetSongStreamAsync(string songId, CancellationToke } /// - public async Task> GetLikedSongsAsync(CancellationToken cancellationToken = default) { + public Task> GetLikedSongsAsync(CancellationToken cancellationToken = default) { // Guest access doesn't support likes. Phase 2 would require AuthToken. - return []; + return Task.FromResult>([]); } /// @@ -181,25 +135,25 @@ public async Task> GetPlaylistSongsAsync(Playlist playlist, Cancellat /// public async Task> GetPlaylistSongsFromUrlAsync(string url, CancellationToken cancellationToken = default) { var result = await GetAsync("resolve", new Dictionary { { "url", url } }, cancellationToken); - + if (result.TryGetProperty("kind", out var kind)) { - var kindStr = kind.GetString(); - var json = result.GetRawText(); - if (kindStr == "playlist") { - var playlist = System.Text.Json.JsonSerializer.Deserialize(json); - return playlist?.Tracks.Select(SoundCloudMapper.MapTrackToSong).ToList() ?? []; - } else if (kindStr == "track") { - var track = System.Text.Json.JsonSerializer.Deserialize(json); - return track != null ? [SoundCloudMapper.MapTrackToSong(track)] : []; - } + var kindStr = kind.GetString(); + var json = result.GetRawText(); + if (kindStr == "playlist") { + var playlist = System.Text.Json.JsonSerializer.Deserialize(json); + return playlist?.Tracks.Select(SoundCloudMapper.MapTrackToSong).ToList() ?? []; + } else if (kindStr == "track") { + var track = System.Text.Json.JsonSerializer.Deserialize(json); + return track != null ? [SoundCloudMapper.MapTrackToSong(track)] : []; + } } - + return []; } /// public async Task AddToListenHistory(Song song, CancellationToken cancellationToken = default) { - await Task.CompletedTask; + await Task.CompletedTask; } /// @@ -219,4 +173,40 @@ public async Task> GetAlbumArtAsync(Album album, Func(albumArt, cancellationToken); } - } \ No newline at end of file + private async Task GetClientIdAsync(CancellationToken cancellationToken = default) { + if (!string.IsNullOrEmpty(_clientId)) { + return _clientId; + } + + Logging.Information("Discovering SoundCloud Client ID..."); + var response = await _httpClient.GetStringAsync(_soundCloudUrl, cancellationToken); + + foreach (var scriptUrl in SoundCloudDiscovery.ExtractScriptUrls(response)) { + if (!scriptUrl.StartsWith("http")) { + continue; + } + + var scriptContent = await _httpClient.GetStringAsync(scriptUrl, cancellationToken); + var clientId = SoundCloudDiscovery.ExtractClientId(scriptContent); + if (clientId != null) { + _clientId = clientId; + Logging.Information($"Discovered SoundCloud Client ID: {_clientId}"); + return _clientId; + } + } + + throw new InvalidOperationException("Could not discover SoundCloud Client ID."); + } + + private async Task GetAsync(string endpoint, Dictionary? parameters = null, CancellationToken cancellationToken = default) { + var clientId = await GetClientIdAsync(cancellationToken); + var url = endpoint.StartsWith("http") ? endpoint : $"{_apiUrl}/{endpoint.TrimStart('/')}"; + var queryParams = parameters ?? new Dictionary(); + queryParams["client_id"] = clientId; + + var queryString = string.Join("&", queryParams.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); + var fullUrl = url.Contains('?') ? $"{url}&{queryString}" : $"{url}?{queryString}"; + + return await _httpClient.GetFromJsonAsync(fullUrl, cancellationToken) ?? throw new InvalidOperationException("API returned null."); + } +} \ No newline at end of file diff --git a/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs b/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs index 5056e32..3d02773 100644 --- a/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs +++ b/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs @@ -6,8 +6,8 @@ namespace Smoc.Streaming.SoundCloud.Util; /// Provides methods for discovering SoundCloud client configuration from the web page. /// public static class SoundCloudDiscovery { - private static readonly Regex ScriptRegex = new Regex("]+src=\"([^\"]+)\"", RegexOptions.IgnoreCase); - private static readonly Regex ClientIdRegex = new Regex("client_id:\"([a-zA-Z0-9]{32})\""); + private static readonly Regex _scriptRegex = new Regex("]+src=\"([^\"]+)\"", RegexOptions.IgnoreCase); + private static readonly Regex _clientIdRegex = new Regex("client_id:\"([a-zA-Z0-9]{32})\""); /// /// Extracts script URLs from the provided HTML. @@ -15,7 +15,7 @@ public static class SoundCloudDiscovery { /// The HTML to extract URLs from. /// A collection of script URLs. public static IEnumerable ExtractScriptUrls(string html) { - return ScriptRegex.Matches(html).Select(m => m.Groups[1].Value); + return _scriptRegex.Matches(html).Select(m => m.Groups[1].Value); } /// @@ -24,7 +24,7 @@ public static IEnumerable ExtractScriptUrls(string html) { /// The script content to search. /// The client ID if found; otherwise, null. public static string? ExtractClientId(string scriptContent) { - var match = ClientIdRegex.Match(scriptContent); + var match = _clientIdRegex.Match(scriptContent); return match.Success ? match.Groups[1].Value : null; } } \ No newline at end of file diff --git a/smoc/Ui/Components/SearchResultsList.cs b/smoc/Ui/Components/SearchResultsList.cs index 467c4ca..41be5e4 100644 --- a/smoc/Ui/Components/SearchResultsList.cs +++ b/smoc/Ui/Components/SearchResultsList.cs @@ -9,7 +9,7 @@ namespace Smoc.Ui.Components; /// public sealed class SearchResultsList : ListView { // TODO: Extract this so that its shared with the actual command bindings - private static readonly Key[] CommandKeys = [new Key(':'), new Key(':').WithShift]; + private static readonly Key[] _commandKeys = [new Key(':'), new Key(':').WithShift]; /// /// Occurs when the user selects a search result. @@ -33,7 +33,7 @@ public SearchResultsList() } protected override bool OnKeyDown(Key key) { - if (CommandKeys.Contains(key)) { + if (_commandKeys.Contains(key)) { return false; }