diff --git a/smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs b/smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs new file mode 100644 index 0000000..849108d --- /dev/null +++ b/smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs @@ -0,0 +1,40 @@ +using Smoc.Streaming.SoundCloud.Util; + +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 = ""; + 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]); + } + + /// + /// 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\", ...}"; + var id = SoundCloudDiscovery.ExtractClientId(content); + 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');"; + 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..385ca2e --- /dev/null +++ b/smoc.Tests/Streaming/SoundCloud/SoundCloudMappingTest.cs @@ -0,0 +1,43 @@ +using Smoc.Streaming.SoundCloud.Models; +using Smoc.Streaming.SoundCloud.Util; + +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"); + 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); + } + + /// + /// 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"); + 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..c9a7a3e --- /dev/null +++ b/smoc.Tests/Streaming/SoundCloud/SoundCloudStreamingClientServiceTest.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Text.Json; +using Smoc.Streaming.SoundCloud; +using Smoc.Streaming.SoundCloud.Models; + +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( + [ + 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); + } + + /// + /// 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 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/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/Models/SoundCloudPlaylist.cs b/smoc/Streaming/SoundCloud/Models/SoundCloudPlaylist.cs new file mode 100644 index 0000000..cc0abd3 --- /dev/null +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudPlaylist.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +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, + [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..b6ada2f --- /dev/null +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudSearchResponse.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +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 +); \ 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..5b9b01f --- /dev/null +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudStreamResponse.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +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 new file mode 100644 index 0000000..d7482c0 --- /dev/null +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudTrack.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +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, + [property: JsonPropertyName("duration")] long Duration, + [property: JsonPropertyName("artwork_url")] string? ArtworkUrl, + [property: JsonPropertyName("user")] SoundCloudUser User, + [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 +); \ 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..23b51d2 --- /dev/null +++ b/smoc/Streaming/SoundCloud/Models/SoundCloudUser.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +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, + [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..fc6a873 --- /dev/null +++ b/smoc/Streaming/SoundCloud/SoundCloudStreamingClient.cs @@ -0,0 +1,212 @@ +using System.Net.Http.Json; +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; + +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 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; + } + + /// + /// 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); + httpClientField?.SetValue(client, httpClient); + client._clientId = clientId; + 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); + } + + /// + 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))]; + } + + /// + 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)]; + } + + /// + 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 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 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" }, { "access", "[playable]" } }, cancellationToken); + return [.. response.Collection.Select(SoundCloudMapper.MapTrackToSong)]; + } + 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()) ?? throw new InvalidOperationException("No playable transcoding found."); + var stream = await _songCacheService.GetOrAddAsync( + $"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"; + return new SongStream(songId, codec, stream); + } + + /// + public Task> GetLikedSongsAsync(CancellationToken cancellationToken = default) { + // Guest access doesn't support likes. Phase 2 would require AuthToken. + return Task.FromResult>([]); + } + + /// + 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); + } + + 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 new file mode 100644 index 0000000..3d02773 --- /dev/null +++ b/smoc/Streaming/SoundCloud/Util/SoundCloudDiscovery.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; + +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; + } +} \ 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..64a0d41 --- /dev/null +++ b/smoc/Streaming/SoundCloud/Util/SoundCloudMapper.cs @@ -0,0 +1,21 @@ +using Smoc.Streaming.SoundCloud.Models; + +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", + 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 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; }