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("